Skip to main content

ff_probe/
info.rs

1//! Media file information extraction.
2//!
3//! This module provides the [`open`] function for extracting metadata from media files
4//! using `FFmpeg`. It creates a [`MediaInfo`] struct containing all relevant information
5//! about the media file, including container format, duration, file size, and stream details.
6//!
7//! # Examples
8//!
9//! ## Basic Usage
10//!
11//! ```no_run
12//! use ff_probe::open;
13//!
14//! fn main() -> Result<(), Box<dyn std::error::Error>> {
15//!     let info = open("video.mp4")?;
16//!
17//!     println!("Format: {}", info.format());
18//!     println!("Duration: {:?}", info.duration());
19//!
20//!     // Access video stream information
21//!     if let Some(video) = info.primary_video() {
22//!         println!("Video: {} {}x{} @ {:.2} fps",
23//!             video.codec_name(),
24//!             video.width(),
25//!             video.height(),
26//!             video.fps()
27//!         );
28//!     }
29//!
30//!     Ok(())
31//! }
32//! ```
33//!
34//! ## Checking for Video vs Audio-Only Files
35//!
36//! ```no_run
37//! use ff_probe::open;
38//!
39//! fn main() -> Result<(), Box<dyn std::error::Error>> {
40//!     let info = open("media_file.mp4")?;
41//!
42//!     if info.has_video() {
43//!         println!("This is a video file");
44//!     } else if info.has_audio() {
45//!         println!("This is an audio-only file");
46//!     }
47//!
48//!     Ok(())
49//! }
50//! ```
51
52// This module requires unsafe code for FFmpeg FFI interactions
53#![allow(unsafe_code)]
54
55use std::collections::HashMap;
56use std::ffi::CStr;
57use std::path::Path;
58use std::time::Duration;
59
60use ff_format::channel::ChannelLayout;
61use ff_format::chapter::ChapterInfo;
62use ff_format::codec::{AudioCodec, SubtitleCodec, VideoCodec};
63use ff_format::color::{ColorPrimaries, ColorRange, ColorSpace};
64use ff_format::stream::{AudioStreamInfo, SubtitleStreamInfo, VideoStreamInfo};
65use ff_format::{MediaInfo, PixelFormat, Rational, SampleFormat};
66
67use crate::error::ProbeError;
68
69/// `AV_TIME_BASE` constant from `FFmpeg` (microseconds per second).
70const AV_TIME_BASE: i64 = 1_000_000;
71
72/// Opens a media file and extracts its metadata.
73///
74/// This function opens the file at the given path using `FFmpeg`, reads the container
75/// format information, and returns a [`MediaInfo`] struct containing all extracted
76/// metadata.
77///
78/// # Arguments
79///
80/// * `path` - Path to the media file to probe. Accepts anything that can be converted
81///   to a [`Path`], including `&str`, `String`, `PathBuf`, etc.
82///
83/// # Returns
84///
85/// Returns `Ok(MediaInfo)` on success, or a [`ProbeError`] on failure.
86///
87/// # Errors
88///
89/// - [`ProbeError::FileNotFound`] if the file does not exist
90/// - [`ProbeError::CannotOpen`] if `FFmpeg` cannot open the file
91/// - [`ProbeError::InvalidMedia`] if stream information cannot be read
92/// - [`ProbeError::Io`] if there's an I/O error accessing the file
93///
94/// # Examples
95///
96/// ## Opening a Video File
97///
98/// ```no_run
99/// use ff_probe::open;
100/// use std::path::Path;
101///
102/// fn main() -> Result<(), Box<dyn std::error::Error>> {
103///     // Open by string path
104///     let info = open("video.mp4")?;
105///
106///     // Or by Path
107///     let path = Path::new("/path/to/video.mkv");
108///     let info = open(path)?;
109///
110///     if let Some(video) = info.primary_video() {
111///         println!("Resolution: {}x{}", video.width(), video.height());
112///     }
113///
114///     Ok(())
115/// }
116/// ```
117///
118/// ## Handling Errors
119///
120/// ```
121/// use ff_probe::{open, ProbeError};
122///
123/// // Non-existent file returns FileNotFound
124/// let result = open("/this/file/does/not/exist.mp4");
125/// assert!(matches!(result, Err(ProbeError::FileNotFound { .. })));
126/// ```
127pub fn open(path: impl AsRef<Path>) -> Result<MediaInfo, ProbeError> {
128    let path = path.as_ref();
129
130    log::debug!("probing media file path={}", path.display());
131
132    // Check if file exists
133    if !path.exists() {
134        return Err(ProbeError::FileNotFound {
135            path: path.to_path_buf(),
136        });
137    }
138
139    // Get file size - propagate error since file may exist but be inaccessible (permission denied, etc.)
140    let file_size = std::fs::metadata(path).map(|m| m.len())?;
141
142    // Open file with FFmpeg
143    // SAFETY: We verified the file exists, and we properly close the context on all paths
144    let ctx = unsafe { ff_sys::avformat::open_input(path) }.map_err(|err_code| {
145        ProbeError::CannotOpen {
146            path: path.to_path_buf(),
147            reason: ff_sys::av_error_string(err_code),
148        }
149    })?;
150
151    // Find stream info - this populates codec information
152    // SAFETY: ctx is valid from open_input
153    if let Err(err_code) = unsafe { ff_sys::avformat::find_stream_info(ctx) } {
154        // SAFETY: ctx is valid
155        unsafe {
156            let mut ctx_ptr = ctx;
157            ff_sys::avformat::close_input(&raw mut ctx_ptr);
158        }
159        return Err(ProbeError::InvalidMedia {
160            path: path.to_path_buf(),
161            reason: ff_sys::av_error_string(err_code),
162        });
163    }
164
165    // Extract basic information from AVFormatContext
166    // SAFETY: ctx is valid and find_stream_info succeeded
167    let (format, format_long_name, duration) = unsafe { extract_format_info(ctx) };
168
169    // Calculate container bitrate
170    // SAFETY: ctx is valid and find_stream_info succeeded
171    let bitrate = unsafe { calculate_container_bitrate(ctx, file_size, duration) };
172
173    // Extract container metadata
174    // SAFETY: ctx is valid and find_stream_info succeeded
175    let metadata = unsafe { extract_metadata(ctx) };
176
177    // Extract video streams
178    // SAFETY: ctx is valid and find_stream_info succeeded
179    let video_streams = unsafe { extract_video_streams(ctx) };
180
181    // Extract audio streams
182    // SAFETY: ctx is valid and find_stream_info succeeded
183    let audio_streams = unsafe { extract_audio_streams(ctx) };
184
185    // Extract subtitle streams
186    // SAFETY: ctx is valid and find_stream_info succeeded
187    let subtitle_streams = unsafe { extract_subtitle_streams(ctx) };
188
189    // Extract chapter info
190    // SAFETY: ctx is valid and find_stream_info succeeded
191    let chapters = unsafe { extract_chapters(ctx) };
192
193    // Close the format context
194    // SAFETY: ctx is valid
195    unsafe {
196        let mut ctx_ptr = ctx;
197        ff_sys::avformat::close_input(&raw mut ctx_ptr);
198    }
199
200    log::debug!(
201        "probe complete video_streams={} audio_streams={} subtitle_streams={} chapters={}",
202        video_streams.len(),
203        audio_streams.len(),
204        subtitle_streams.len(),
205        chapters.len()
206    );
207
208    // Build MediaInfo
209    let mut builder = MediaInfo::builder()
210        .path(path)
211        .format(format)
212        .duration(duration)
213        .file_size(file_size)
214        .video_streams(video_streams)
215        .audio_streams(audio_streams)
216        .subtitle_streams(subtitle_streams)
217        .chapters(chapters)
218        .metadata_map(metadata);
219
220    if let Some(name) = format_long_name {
221        builder = builder.format_long_name(name);
222    }
223
224    if let Some(bps) = bitrate {
225        builder = builder.bitrate(bps);
226    }
227
228    Ok(builder.build())
229}
230
231/// Extracts format information from an `AVFormatContext`.
232///
233/// # Safety
234///
235/// The `ctx` pointer must be valid and properly initialized by `avformat_open_input`.
236unsafe fn extract_format_info(
237    ctx: *mut ff_sys::AVFormatContext,
238) -> (String, Option<String>, Duration) {
239    // SAFETY: Caller guarantees ctx is valid
240    unsafe {
241        let format = extract_format_name(ctx);
242        let format_long_name = extract_format_long_name(ctx);
243        let duration = extract_duration(ctx);
244
245        (format, format_long_name, duration)
246    }
247}
248
249/// Extracts the format name from an `AVFormatContext`.
250///
251/// # Safety
252///
253/// The `ctx` pointer must be valid.
254unsafe fn extract_format_name(ctx: *mut ff_sys::AVFormatContext) -> String {
255    // SAFETY: Caller guarantees ctx is valid
256    unsafe {
257        let iformat = (*ctx).iformat;
258        if iformat.is_null() {
259            return String::from("unknown");
260        }
261
262        let name_ptr = (*iformat).name;
263        if name_ptr.is_null() {
264            return String::from("unknown");
265        }
266
267        CStr::from_ptr(name_ptr).to_string_lossy().into_owned()
268    }
269}
270
271/// Extracts the long format name from an `AVFormatContext`.
272///
273/// # Safety
274///
275/// The `ctx` pointer must be valid.
276unsafe fn extract_format_long_name(ctx: *mut ff_sys::AVFormatContext) -> Option<String> {
277    // SAFETY: Caller guarantees ctx is valid
278    unsafe {
279        let iformat = (*ctx).iformat;
280        if iformat.is_null() {
281            return None;
282        }
283
284        let long_name_ptr = (*iformat).long_name;
285        if long_name_ptr.is_null() {
286            return None;
287        }
288
289        Some(CStr::from_ptr(long_name_ptr).to_string_lossy().into_owned())
290    }
291}
292
293/// Extracts the duration from an `AVFormatContext`.
294///
295/// The duration is stored in `AV_TIME_BASE` units (microseconds).
296/// If the duration is not available or is invalid, returns `Duration::ZERO`.
297///
298/// # Safety
299///
300/// The `ctx` pointer must be valid.
301unsafe fn extract_duration(ctx: *mut ff_sys::AVFormatContext) -> Duration {
302    // SAFETY: Caller guarantees ctx is valid
303    let duration_us = unsafe { (*ctx).duration };
304
305    // duration_us == 0: Container does not provide duration info (e.g., live streams)
306    // duration_us < 0: AV_NOPTS_VALUE (typically i64::MIN), indicating unknown duration
307    if duration_us <= 0 {
308        return Duration::ZERO;
309    }
310
311    // Convert from microseconds to Duration
312    // duration is in AV_TIME_BASE units (1/1000000 seconds)
313    // Safe cast: we verified duration_us > 0 above
314    #[expect(clippy::cast_sign_loss, reason = "verified duration_us > 0")]
315    let secs = (duration_us / AV_TIME_BASE) as u64;
316    #[expect(clippy::cast_sign_loss, reason = "verified duration_us > 0")]
317    let micros = (duration_us % AV_TIME_BASE) as u32;
318
319    Duration::new(secs, micros * 1000)
320}
321
322/// Calculates the overall bitrate for a media file.
323///
324/// This function first tries to get the bitrate directly from the `AVFormatContext`.
325/// If the bitrate is not available (i.e., 0 or negative), it falls back to calculating
326/// the bitrate from the file size and duration: `bitrate = file_size * 8 / duration`.
327///
328/// # Arguments
329///
330/// * `ctx` - The `AVFormatContext` to extract bitrate from
331/// * `file_size` - The file size in bytes
332/// * `duration` - The duration of the media
333///
334/// # Returns
335///
336/// Returns `Some(bitrate)` in bits per second, or `None` if neither method can determine
337/// the bitrate (e.g., if duration is zero).
338///
339/// # Safety
340///
341/// The `ctx` pointer must be valid.
342unsafe fn calculate_container_bitrate(
343    ctx: *mut ff_sys::AVFormatContext,
344    file_size: u64,
345    duration: Duration,
346) -> Option<u64> {
347    // SAFETY: Caller guarantees ctx is valid
348    let bitrate = unsafe { (*ctx).bit_rate };
349
350    // If bitrate is available from FFmpeg, use it directly
351    if bitrate > 0 {
352        #[expect(clippy::cast_sign_loss, reason = "verified bitrate > 0")]
353        return Some(bitrate as u64);
354    }
355
356    // Fallback: calculate from file size and duration
357    // bitrate (bps) = file_size (bytes) * 8 (bits/byte) / duration (seconds)
358    let duration_secs = duration.as_secs_f64();
359    if duration_secs > 0.0 && file_size > 0 {
360        // Note: Precision loss from u64->f64 is acceptable here because:
361        // 1. For files up to 9 PB, f64 provides sufficient precision
362        // 2. The result is used for display/metadata purposes, not exact calculations
363        #[expect(
364            clippy::cast_precision_loss,
365            reason = "precision loss acceptable for file size; f64 handles up to 9 PB"
366        )]
367        let file_size_f64 = file_size as f64;
368
369        #[expect(
370            clippy::cast_possible_truncation,
371            reason = "bitrate values are bounded by practical file sizes"
372        )]
373        #[expect(
374            clippy::cast_sign_loss,
375            reason = "result is always positive since both operands are positive"
376        )]
377        let calculated_bitrate = (file_size_f64 * 8.0 / duration_secs) as u64;
378        Some(calculated_bitrate)
379    } else {
380        None
381    }
382}
383
384// ============================================================================
385// Container Metadata Extraction
386// ============================================================================
387
388/// Extracts container-level metadata from an `AVFormatContext`.
389///
390/// This function reads all metadata entries from the container's `AVDictionary`,
391/// including standard keys (title, artist, album, date, etc.) and custom metadata.
392///
393/// # Safety
394///
395/// The `ctx` pointer must be valid.
396///
397/// # Returns
398///
399/// Returns a `HashMap` containing all metadata key-value pairs.
400/// If no metadata is present, returns an empty `HashMap`.
401unsafe fn extract_metadata(ctx: *mut ff_sys::AVFormatContext) -> HashMap<String, String> {
402    let mut metadata = HashMap::new();
403
404    // SAFETY: Caller guarantees ctx is valid
405    unsafe {
406        let dict = (*ctx).metadata;
407        if dict.is_null() {
408            return metadata;
409        }
410
411        // Iterate through all dictionary entries using av_dict_get with AV_DICT_IGNORE_SUFFIX
412        // This iterates all entries when starting with an empty key and passing the previous entry
413        let mut entry: *const ff_sys::AVDictionaryEntry = std::ptr::null();
414
415        // AV_DICT_IGNORE_SUFFIX is a small constant (2) that safely fits in i32
416        let flags = ff_sys::AV_DICT_IGNORE_SUFFIX.cast_signed();
417
418        loop {
419            // Get the next entry by passing the previous one
420            // Using empty string as key and AV_DICT_IGNORE_SUFFIX to iterate all entries
421            entry = ff_sys::av_dict_get(dict, c"".as_ptr(), entry, flags);
422
423            if entry.is_null() {
424                break;
425            }
426
427            // Extract key and value from the entry
428            let key_ptr = (*entry).key;
429            let value_ptr = (*entry).value;
430
431            if key_ptr.is_null() || value_ptr.is_null() {
432                continue;
433            }
434
435            // Convert C strings to Rust strings
436            let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
437            let value = CStr::from_ptr(value_ptr).to_string_lossy().into_owned();
438
439            metadata.insert(key, value);
440        }
441    }
442
443    metadata
444}
445
446// ============================================================================
447// Video Stream Extraction
448// ============================================================================
449
450/// Extracts all video streams from an `AVFormatContext`.
451///
452/// This function iterates through all streams in the container and extracts
453/// detailed information for each video stream.
454///
455/// # Safety
456///
457/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
458unsafe fn extract_video_streams(ctx: *mut ff_sys::AVFormatContext) -> Vec<VideoStreamInfo> {
459    // SAFETY: Caller guarantees ctx is valid
460    unsafe {
461        let nb_streams = (*ctx).nb_streams;
462        let streams_ptr = (*ctx).streams;
463
464        if streams_ptr.is_null() || nb_streams == 0 {
465            return Vec::new();
466        }
467
468        let mut video_streams = Vec::new();
469
470        for i in 0..nb_streams {
471            // SAFETY: i < nb_streams, so this is within bounds
472            let stream = *streams_ptr.add(i as usize);
473            if stream.is_null() {
474                continue;
475            }
476
477            let codecpar = (*stream).codecpar;
478            if codecpar.is_null() {
479                continue;
480            }
481
482            // Check if this is a video stream
483            if (*codecpar).codec_type != ff_sys::AVMediaType_AVMEDIA_TYPE_VIDEO {
484                continue;
485            }
486
487            // Extract video stream info
488            let stream_info = extract_single_video_stream(stream, codecpar, i);
489            video_streams.push(stream_info);
490        }
491
492        video_streams
493    }
494}
495
496/// Extracts information from a single video stream.
497///
498/// # Safety
499///
500/// Both `stream` and `codecpar` pointers must be valid.
501unsafe fn extract_single_video_stream(
502    stream: *mut ff_sys::AVStream,
503    codecpar: *mut ff_sys::AVCodecParameters,
504    index: u32,
505) -> VideoStreamInfo {
506    // SAFETY: Caller guarantees pointers are valid
507    unsafe {
508        // Extract codec info
509        let codec_id = (*codecpar).codec_id;
510        let codec = map_video_codec(codec_id);
511        let codec_name = extract_codec_name(codec_id);
512
513        // Extract dimensions
514        #[expect(clippy::cast_sign_loss, reason = "width/height are always positive")]
515        let width = (*codecpar).width as u32;
516        #[expect(clippy::cast_sign_loss, reason = "width/height are always positive")]
517        let height = (*codecpar).height as u32;
518
519        // Extract pixel format
520        let pixel_format = map_pixel_format((*codecpar).format);
521
522        // Extract frame rate
523        let frame_rate = extract_frame_rate(stream);
524
525        // Extract bitrate
526        let bitrate = extract_stream_bitrate(codecpar);
527
528        // Extract color information
529        let color_space = map_color_space((*codecpar).color_space);
530        let color_range = map_color_range((*codecpar).color_range);
531        let color_primaries = map_color_primaries((*codecpar).color_primaries);
532
533        // Extract duration if available
534        let duration = extract_stream_duration(stream);
535
536        // Extract frame count if available
537        let frame_count = extract_frame_count(stream);
538
539        // Build the VideoStreamInfo
540        let mut builder = VideoStreamInfo::builder()
541            .index(index)
542            .codec(codec)
543            .codec_name(codec_name)
544            .width(width)
545            .height(height)
546            .pixel_format(pixel_format)
547            .frame_rate(frame_rate)
548            .color_space(color_space)
549            .color_range(color_range)
550            .color_primaries(color_primaries);
551
552        if let Some(d) = duration {
553            builder = builder.duration(d);
554        }
555
556        if let Some(b) = bitrate {
557            builder = builder.bitrate(b);
558        }
559
560        if let Some(c) = frame_count {
561            builder = builder.frame_count(c);
562        }
563
564        builder.build()
565    }
566}
567
568/// Extracts the codec name from an `AVCodecID`.
569///
570/// # Safety
571///
572/// This function calls `FFmpeg`'s `avcodec_get_name` which is safe for any codec ID.
573unsafe fn extract_codec_name(codec_id: ff_sys::AVCodecID) -> String {
574    // SAFETY: avcodec_get_name is safe for any codec ID value
575    let name_ptr = unsafe { ff_sys::avcodec_get_name(codec_id) };
576
577    if name_ptr.is_null() {
578        return String::from("unknown");
579    }
580
581    // SAFETY: avcodec_get_name returns a valid C string
582    unsafe { CStr::from_ptr(name_ptr).to_string_lossy().into_owned() }
583}
584
585/// Extracts the frame rate from an `AVStream`.
586///
587/// Tries to get the real frame rate (`r_frame_rate`), falling back to average
588/// frame rate (`avg_frame_rate`), and finally to a default of 30/1.
589///
590/// # Safety
591///
592/// The `stream` pointer must be valid.
593unsafe fn extract_frame_rate(stream: *mut ff_sys::AVStream) -> Rational {
594    // SAFETY: Caller guarantees stream is valid
595    unsafe {
596        // Try r_frame_rate first (real frame rate, most accurate for video)
597        let r_frame_rate = (*stream).r_frame_rate;
598        if r_frame_rate.den > 0 && r_frame_rate.num > 0 {
599            return Rational::new(r_frame_rate.num, r_frame_rate.den);
600        }
601
602        // Fall back to avg_frame_rate
603        let avg_frame_rate = (*stream).avg_frame_rate;
604        if avg_frame_rate.den > 0 && avg_frame_rate.num > 0 {
605            return Rational::new(avg_frame_rate.num, avg_frame_rate.den);
606        }
607
608        // Default to 30 fps
609        {
610            log::warn!(
611                "frame_rate unavailable, falling back to 30fps \
612                 r_frame_rate={}/{} avg_frame_rate={}/{} fallback=30/1",
613                r_frame_rate.num,
614                r_frame_rate.den,
615                avg_frame_rate.num,
616                avg_frame_rate.den
617            );
618            Rational::new(30, 1)
619        }
620    }
621}
622
623/// Extracts the bitrate from an `AVCodecParameters`.
624///
625/// Returns `None` if the bitrate is not available or is zero.
626///
627/// # Safety
628///
629/// The `codecpar` pointer must be valid.
630unsafe fn extract_stream_bitrate(codecpar: *mut ff_sys::AVCodecParameters) -> Option<u64> {
631    // SAFETY: Caller guarantees codecpar is valid
632    let bitrate = unsafe { (*codecpar).bit_rate };
633
634    if bitrate > 0 {
635        #[expect(clippy::cast_sign_loss, reason = "verified bitrate > 0")]
636        Some(bitrate as u64)
637    } else {
638        None
639    }
640}
641
642/// Extracts the duration from an `AVStream`.
643///
644/// Returns `None` if the duration is not available.
645///
646/// # Safety
647///
648/// The `stream` pointer must be valid.
649unsafe fn extract_stream_duration(stream: *mut ff_sys::AVStream) -> Option<Duration> {
650    // SAFETY: Caller guarantees stream is valid
651    unsafe {
652        let duration_pts = (*stream).duration;
653
654        // AV_NOPTS_VALUE indicates unknown duration
655        if duration_pts <= 0 {
656            return None;
657        }
658
659        // Get stream time base
660        let time_base = (*stream).time_base;
661        if time_base.den == 0 {
662            return None;
663        }
664
665        // Convert to seconds: pts * num / den
666        // Note: i64 to f64 cast may lose precision for very large values,
667        // but this is acceptable for media timestamps which are bounded
668        #[expect(clippy::cast_precision_loss, reason = "media timestamps are bounded")]
669        let secs = (duration_pts as f64) * f64::from(time_base.num) / f64::from(time_base.den);
670
671        if secs > 0.0 {
672            Some(Duration::from_secs_f64(secs))
673        } else {
674            None
675        }
676    }
677}
678
679/// Extracts the frame count from an `AVStream`.
680///
681/// Returns `None` if the frame count is not available.
682///
683/// # Safety
684///
685/// The `stream` pointer must be valid.
686unsafe fn extract_frame_count(stream: *mut ff_sys::AVStream) -> Option<u64> {
687    // SAFETY: Caller guarantees stream is valid
688    let nb_frames = unsafe { (*stream).nb_frames };
689
690    if nb_frames > 0 {
691        #[expect(clippy::cast_sign_loss, reason = "verified nb_frames > 0")]
692        Some(nb_frames as u64)
693    } else {
694        None
695    }
696}
697
698// ============================================================================
699// Type Mapping Functions
700// ============================================================================
701
702/// Maps an `FFmpeg` `AVCodecID` to our [`VideoCodec`] enum.
703fn map_video_codec(codec_id: ff_sys::AVCodecID) -> VideoCodec {
704    match codec_id {
705        ff_sys::AVCodecID_AV_CODEC_ID_H264 => VideoCodec::H264,
706        ff_sys::AVCodecID_AV_CODEC_ID_HEVC => VideoCodec::H265,
707        ff_sys::AVCodecID_AV_CODEC_ID_VP8 => VideoCodec::Vp8,
708        ff_sys::AVCodecID_AV_CODEC_ID_VP9 => VideoCodec::Vp9,
709        ff_sys::AVCodecID_AV_CODEC_ID_AV1 => VideoCodec::Av1,
710        ff_sys::AVCodecID_AV_CODEC_ID_PRORES => VideoCodec::ProRes,
711        ff_sys::AVCodecID_AV_CODEC_ID_MPEG4 => VideoCodec::Mpeg4,
712        ff_sys::AVCodecID_AV_CODEC_ID_MPEG2VIDEO => VideoCodec::Mpeg2,
713        ff_sys::AVCodecID_AV_CODEC_ID_MJPEG => VideoCodec::Mjpeg,
714        _ => {
715            log::warn!(
716                "video_codec has no mapping, using Unknown \
717                 codec_id={codec_id}"
718            );
719            VideoCodec::Unknown
720        }
721    }
722}
723
724/// Maps an `FFmpeg` `AVPixelFormat` to our [`PixelFormat`] enum.
725fn map_pixel_format(format: i32) -> PixelFormat {
726    #[expect(clippy::cast_sign_loss, reason = "AVPixelFormat values are positive")]
727    let format_u32 = format as u32;
728
729    match format_u32 {
730        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_RGB24 as u32 => PixelFormat::Rgb24,
731        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_RGBA as u32 => PixelFormat::Rgba,
732        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_BGR24 as u32 => PixelFormat::Bgr24,
733        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_BGRA as u32 => PixelFormat::Bgra,
734        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P as u32 => PixelFormat::Yuv420p,
735        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV422P as u32 => PixelFormat::Yuv422p,
736        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV444P as u32 => PixelFormat::Yuv444p,
737        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_NV12 as u32 => PixelFormat::Nv12,
738        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_NV21 as u32 => PixelFormat::Nv21,
739        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P10LE as u32 => PixelFormat::Yuv420p10le,
740        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_P010LE as u32 => PixelFormat::P010le,
741        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_GRAY8 as u32 => PixelFormat::Gray8,
742        _ => {
743            log::warn!(
744                "pixel_format has no mapping, using Other \
745                 format={format_u32}"
746            );
747            PixelFormat::Other(format_u32)
748        }
749    }
750}
751
752/// Maps an `FFmpeg` `AVColorSpace` to our [`ColorSpace`] enum.
753fn map_color_space(color_space: ff_sys::AVColorSpace) -> ColorSpace {
754    match color_space {
755        ff_sys::AVColorSpace_AVCOL_SPC_BT709 => ColorSpace::Bt709,
756        ff_sys::AVColorSpace_AVCOL_SPC_BT470BG | ff_sys::AVColorSpace_AVCOL_SPC_SMPTE170M => {
757            ColorSpace::Bt601
758        }
759        ff_sys::AVColorSpace_AVCOL_SPC_BT2020_NCL | ff_sys::AVColorSpace_AVCOL_SPC_BT2020_CL => {
760            ColorSpace::Bt2020
761        }
762        ff_sys::AVColorSpace_AVCOL_SPC_RGB => ColorSpace::Srgb,
763        _ => {
764            log::warn!(
765                "color_space has no mapping, using Unknown \
766                 color_space={color_space}"
767            );
768            ColorSpace::Unknown
769        }
770    }
771}
772
773/// Maps an `FFmpeg` `AVColorRange` to our [`ColorRange`] enum.
774fn map_color_range(color_range: ff_sys::AVColorRange) -> ColorRange {
775    match color_range {
776        ff_sys::AVColorRange_AVCOL_RANGE_MPEG => ColorRange::Limited,
777        ff_sys::AVColorRange_AVCOL_RANGE_JPEG => ColorRange::Full,
778        _ => {
779            log::warn!(
780                "color_range has no mapping, using Unknown \
781                 color_range={color_range}"
782            );
783            ColorRange::Unknown
784        }
785    }
786}
787
788/// Maps an `FFmpeg` `AVColorPrimaries` to our [`ColorPrimaries`] enum.
789fn map_color_primaries(color_primaries: ff_sys::AVColorPrimaries) -> ColorPrimaries {
790    match color_primaries {
791        ff_sys::AVColorPrimaries_AVCOL_PRI_BT709 => ColorPrimaries::Bt709,
792        ff_sys::AVColorPrimaries_AVCOL_PRI_BT470BG
793        | ff_sys::AVColorPrimaries_AVCOL_PRI_SMPTE170M => ColorPrimaries::Bt601,
794        ff_sys::AVColorPrimaries_AVCOL_PRI_BT2020 => ColorPrimaries::Bt2020,
795        _ => {
796            log::warn!(
797                "color_primaries has no mapping, using Unknown \
798                 color_primaries={color_primaries}"
799            );
800            ColorPrimaries::Unknown
801        }
802    }
803}
804
805// ============================================================================
806// Audio Stream Extraction
807// ============================================================================
808
809/// Extracts all audio streams from an `AVFormatContext`.
810///
811/// This function iterates through all streams in the container and extracts
812/// detailed information for each audio stream.
813///
814/// # Safety
815///
816/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
817unsafe fn extract_audio_streams(ctx: *mut ff_sys::AVFormatContext) -> Vec<AudioStreamInfo> {
818    // SAFETY: Caller guarantees ctx is valid and find_stream_info was called
819    unsafe {
820        let nb_streams = (*ctx).nb_streams;
821        let streams_ptr = (*ctx).streams;
822
823        if streams_ptr.is_null() || nb_streams == 0 {
824            return Vec::new();
825        }
826
827        let mut audio_streams = Vec::new();
828
829        for i in 0..nb_streams {
830            // SAFETY: i < nb_streams, so this is within bounds
831            let stream = *streams_ptr.add(i as usize);
832            if stream.is_null() {
833                continue;
834            }
835
836            let codecpar = (*stream).codecpar;
837            if codecpar.is_null() {
838                continue;
839            }
840
841            // Check if this is an audio stream
842            if (*codecpar).codec_type != ff_sys::AVMediaType_AVMEDIA_TYPE_AUDIO {
843                continue;
844            }
845
846            // Extract audio stream info
847            let stream_info = extract_single_audio_stream(stream, codecpar, i);
848            audio_streams.push(stream_info);
849        }
850
851        audio_streams
852    }
853}
854
855/// Extracts information from a single audio stream.
856///
857/// # Safety
858///
859/// Both `stream` and `codecpar` pointers must be valid.
860unsafe fn extract_single_audio_stream(
861    stream: *mut ff_sys::AVStream,
862    codecpar: *mut ff_sys::AVCodecParameters,
863    index: u32,
864) -> AudioStreamInfo {
865    // SAFETY: Caller guarantees pointers are valid
866    unsafe {
867        // Extract codec info
868        let codec_id = (*codecpar).codec_id;
869        let codec = map_audio_codec(codec_id);
870        let codec_name = extract_codec_name(codec_id);
871
872        // Extract audio parameters
873        #[expect(clippy::cast_sign_loss, reason = "sample_rate is always positive")]
874        let sample_rate = (*codecpar).sample_rate as u32;
875
876        // FFmpeg 5.1+ uses ch_layout, older versions use channels
877        let channels = extract_channel_count(codecpar);
878
879        // Extract channel layout
880        let channel_layout = extract_channel_layout(codecpar, channels);
881
882        // Extract sample format
883        let sample_format = map_sample_format((*codecpar).format);
884
885        // Extract bitrate
886        let bitrate = extract_stream_bitrate(codecpar);
887
888        // Extract duration if available
889        let duration = extract_stream_duration(stream);
890
891        // Extract language from stream metadata
892        let language = extract_language(stream);
893
894        // Build the AudioStreamInfo
895        let mut builder = AudioStreamInfo::builder()
896            .index(index)
897            .codec(codec)
898            .codec_name(codec_name)
899            .sample_rate(sample_rate)
900            .channels(channels)
901            .channel_layout(channel_layout)
902            .sample_format(sample_format);
903
904        if let Some(d) = duration {
905            builder = builder.duration(d);
906        }
907
908        if let Some(b) = bitrate {
909            builder = builder.bitrate(b);
910        }
911
912        if let Some(lang) = language {
913            builder = builder.language(lang);
914        }
915
916        builder.build()
917    }
918}
919
920/// Extracts the channel count from `AVCodecParameters`.
921///
922/// `FFmpeg` 5.1+ uses `ch_layout.nb_channels`, older versions used `channels` directly.
923///
924/// Returns the actual channel count from `FFmpeg`. If the channel count is 0 (which
925/// indicates uninitialized or unknown), returns 1 (mono) as a safe minimum.
926///
927/// # Safety
928///
929/// The `codecpar` pointer must be valid.
930unsafe fn extract_channel_count(codecpar: *mut ff_sys::AVCodecParameters) -> u32 {
931    // SAFETY: Caller guarantees codecpar is valid
932    // FFmpeg 5.1+ uses ch_layout structure
933    #[expect(clippy::cast_sign_loss, reason = "channel count is always positive")]
934    let channels = unsafe { (*codecpar).ch_layout.nb_channels as u32 };
935
936    // If channel count is 0 (uninitialized/unknown), use 1 (mono) as safe minimum
937    if channels > 0 {
938        channels
939    } else {
940        log::warn!(
941            "channel_count is 0 (uninitialized), falling back to mono \
942             fallback=1"
943        );
944        1
945    }
946}
947
948/// Extracts the channel layout from `AVCodecParameters`.
949///
950/// # Safety
951///
952/// The `codecpar` pointer must be valid.
953unsafe fn extract_channel_layout(
954    codecpar: *mut ff_sys::AVCodecParameters,
955    channels: u32,
956) -> ChannelLayout {
957    // SAFETY: Caller guarantees codecpar is valid
958    // FFmpeg 5.1+ uses ch_layout structure with channel masks
959    let ch_layout = unsafe { &(*codecpar).ch_layout };
960
961    // Check if we have a specific channel layout mask
962    // AV_CHANNEL_ORDER_NATIVE means we have a valid channel mask
963    if ch_layout.order == ff_sys::AVChannelOrder_AV_CHANNEL_ORDER_NATIVE {
964        // Map common FFmpeg channel masks to our ChannelLayout
965        // These are AVChannelLayout masks for standard configurations
966        // SAFETY: When order is AV_CHANNEL_ORDER_NATIVE, the mask field is valid
967        let mask = unsafe { ch_layout.u.mask };
968        match mask {
969            // AV_CH_LAYOUT_MONO = 0x4 (front center)
970            0x4 => ChannelLayout::Mono,
971            // AV_CH_LAYOUT_STEREO = 0x3 (front left + front right)
972            0x3 => ChannelLayout::Stereo,
973            // AV_CH_LAYOUT_2_1 = 0x103 (stereo + LFE)
974            0x103 => ChannelLayout::Stereo2_1,
975            // AV_CH_LAYOUT_SURROUND = 0x7 (FL + FR + FC)
976            0x7 => ChannelLayout::Surround3_0,
977            // AV_CH_LAYOUT_QUAD = 0x33 (FL + FR + BL + BR)
978            0x33 => ChannelLayout::Quad,
979            // AV_CH_LAYOUT_5POINT0 = 0x37 (FL + FR + FC + BL + BR)
980            0x37 => ChannelLayout::Surround5_0,
981            // AV_CH_LAYOUT_5POINT1 = 0x3F (FL + FR + FC + LFE + BL + BR)
982            0x3F => ChannelLayout::Surround5_1,
983            // AV_CH_LAYOUT_6POINT1 = 0x13F (FL + FR + FC + LFE + BC + SL + SR)
984            0x13F => ChannelLayout::Surround6_1,
985            // AV_CH_LAYOUT_7POINT1 = 0x63F (FL + FR + FC + LFE + BL + BR + SL + SR)
986            0x63F => ChannelLayout::Surround7_1,
987            _ => {
988                log::warn!(
989                    "channel_layout mask has no mapping, deriving from channel count \
990                     mask={mask} channels={channels}"
991                );
992                ChannelLayout::from_channels(channels)
993            }
994        }
995    } else {
996        log::warn!(
997            "channel_layout order is not NATIVE, deriving from channel count \
998             order={order} channels={channels}",
999            order = ch_layout.order
1000        );
1001        ChannelLayout::from_channels(channels)
1002    }
1003}
1004
1005/// Extracts the language tag from stream metadata.
1006///
1007/// # Safety
1008///
1009/// The `stream` pointer must be valid.
1010unsafe fn extract_language(stream: *mut ff_sys::AVStream) -> Option<String> {
1011    // SAFETY: Caller guarantees stream is valid
1012    unsafe {
1013        let metadata = (*stream).metadata;
1014        if metadata.is_null() {
1015            return None;
1016        }
1017
1018        // Look for "language" tag in the stream metadata
1019        let key = c"language";
1020        let entry = ff_sys::av_dict_get(metadata, key.as_ptr(), std::ptr::null(), 0);
1021
1022        if entry.is_null() {
1023            return None;
1024        }
1025
1026        let value_ptr = (*entry).value;
1027        if value_ptr.is_null() {
1028            return None;
1029        }
1030
1031        Some(CStr::from_ptr(value_ptr).to_string_lossy().into_owned())
1032    }
1033}
1034
1035// ============================================================================
1036// Audio Type Mapping Functions
1037// ============================================================================
1038
1039// ============================================================================
1040// Subtitle Stream Extraction
1041// ============================================================================
1042
1043/// Extracts all subtitle streams from an `AVFormatContext`.
1044///
1045/// This function iterates through all streams in the container and extracts
1046/// detailed information for each subtitle stream.
1047///
1048/// # Safety
1049///
1050/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
1051unsafe fn extract_subtitle_streams(ctx: *mut ff_sys::AVFormatContext) -> Vec<SubtitleStreamInfo> {
1052    // SAFETY: Caller guarantees ctx is valid and find_stream_info was called
1053    unsafe {
1054        let nb_streams = (*ctx).nb_streams;
1055        let streams_ptr = (*ctx).streams;
1056
1057        if streams_ptr.is_null() || nb_streams == 0 {
1058            return Vec::new();
1059        }
1060
1061        let mut subtitle_streams = Vec::new();
1062
1063        for i in 0..nb_streams {
1064            // SAFETY: i < nb_streams, so this is within bounds
1065            let stream = *streams_ptr.add(i as usize);
1066            if stream.is_null() {
1067                continue;
1068            }
1069
1070            let codecpar = (*stream).codecpar;
1071            if codecpar.is_null() {
1072                continue;
1073            }
1074
1075            // Check if this is a subtitle stream
1076            if (*codecpar).codec_type != ff_sys::AVMediaType_AVMEDIA_TYPE_SUBTITLE {
1077                continue;
1078            }
1079
1080            let stream_info = extract_single_subtitle_stream(stream, codecpar, i);
1081            subtitle_streams.push(stream_info);
1082        }
1083
1084        subtitle_streams
1085    }
1086}
1087
1088/// Extracts information from a single subtitle stream.
1089///
1090/// # Safety
1091///
1092/// Both `stream` and `codecpar` pointers must be valid.
1093unsafe fn extract_single_subtitle_stream(
1094    stream: *mut ff_sys::AVStream,
1095    codecpar: *mut ff_sys::AVCodecParameters,
1096    index: u32,
1097) -> SubtitleStreamInfo {
1098    // SAFETY: Caller guarantees pointers are valid
1099    unsafe {
1100        let codec_id = (*codecpar).codec_id;
1101        let codec = map_subtitle_codec(codec_id);
1102        let codec_name = extract_codec_name(codec_id);
1103
1104        // disposition is a c_int bitmask; cast to u32 for bitwise AND with the u32 constant
1105        #[expect(
1106            clippy::cast_sign_loss,
1107            reason = "disposition is a non-negative bitmask"
1108        )]
1109        let forced = ((*stream).disposition as u32 & ff_sys::AV_DISPOSITION_FORCED) != 0;
1110
1111        let duration = extract_stream_duration(stream);
1112        let language = extract_language(stream);
1113        let title = extract_stream_title(stream);
1114
1115        let mut builder = SubtitleStreamInfo::builder()
1116            .index(index)
1117            .codec(codec)
1118            .codec_name(codec_name)
1119            .forced(forced);
1120
1121        if let Some(d) = duration {
1122            builder = builder.duration(d);
1123        }
1124        if let Some(lang) = language {
1125            builder = builder.language(lang);
1126        }
1127        if let Some(t) = title {
1128            builder = builder.title(t);
1129        }
1130
1131        builder.build()
1132    }
1133}
1134
1135/// Extracts the "title" metadata tag from a stream's `AVDictionary`.
1136///
1137/// # Safety
1138///
1139/// The `stream` pointer must be valid.
1140unsafe fn extract_stream_title(stream: *mut ff_sys::AVStream) -> Option<String> {
1141    // SAFETY: Caller guarantees stream is valid
1142    unsafe {
1143        let metadata = (*stream).metadata;
1144        if metadata.is_null() {
1145            return None;
1146        }
1147
1148        let key = c"title";
1149        let entry = ff_sys::av_dict_get(metadata, key.as_ptr(), std::ptr::null(), 0);
1150
1151        if entry.is_null() {
1152            return None;
1153        }
1154
1155        let value_ptr = (*entry).value;
1156        if value_ptr.is_null() {
1157            return None;
1158        }
1159
1160        Some(CStr::from_ptr(value_ptr).to_string_lossy().into_owned())
1161    }
1162}
1163
1164/// Maps an `FFmpeg` `AVCodecID` to our [`SubtitleCodec`] enum.
1165fn map_subtitle_codec(codec_id: ff_sys::AVCodecID) -> SubtitleCodec {
1166    match codec_id {
1167        ff_sys::AVCodecID_AV_CODEC_ID_SRT | ff_sys::AVCodecID_AV_CODEC_ID_SUBRIP => {
1168            SubtitleCodec::Srt
1169        }
1170        ff_sys::AVCodecID_AV_CODEC_ID_SSA | ff_sys::AVCodecID_AV_CODEC_ID_ASS => SubtitleCodec::Ass,
1171        ff_sys::AVCodecID_AV_CODEC_ID_DVB_SUBTITLE => SubtitleCodec::Dvb,
1172        ff_sys::AVCodecID_AV_CODEC_ID_HDMV_PGS_SUBTITLE => SubtitleCodec::Hdmv,
1173        ff_sys::AVCodecID_AV_CODEC_ID_WEBVTT => SubtitleCodec::Webvtt,
1174        _ => {
1175            // SAFETY: avcodec_get_name is safe for any codec ID
1176            let name = unsafe { extract_codec_name(codec_id) };
1177            log::warn!("unknown subtitle codec codec_id={codec_id}");
1178            SubtitleCodec::Other(name)
1179        }
1180    }
1181}
1182
1183/// Maps an `FFmpeg` `AVCodecID` to our [`AudioCodec`] enum.
1184fn map_audio_codec(codec_id: ff_sys::AVCodecID) -> AudioCodec {
1185    match codec_id {
1186        ff_sys::AVCodecID_AV_CODEC_ID_AAC => AudioCodec::Aac,
1187        ff_sys::AVCodecID_AV_CODEC_ID_MP3 => AudioCodec::Mp3,
1188        ff_sys::AVCodecID_AV_CODEC_ID_OPUS => AudioCodec::Opus,
1189        ff_sys::AVCodecID_AV_CODEC_ID_FLAC => AudioCodec::Flac,
1190        ff_sys::AVCodecID_AV_CODEC_ID_VORBIS => AudioCodec::Vorbis,
1191        ff_sys::AVCodecID_AV_CODEC_ID_AC3 => AudioCodec::Ac3,
1192        ff_sys::AVCodecID_AV_CODEC_ID_EAC3 => AudioCodec::Eac3,
1193        ff_sys::AVCodecID_AV_CODEC_ID_DTS => AudioCodec::Dts,
1194        ff_sys::AVCodecID_AV_CODEC_ID_ALAC => AudioCodec::Alac,
1195        // PCM variants
1196        ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16LE
1197        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16BE
1198        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S24LE
1199        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S24BE
1200        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S32LE
1201        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S32BE
1202        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F32LE
1203        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F32BE
1204        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F64LE
1205        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F64BE
1206        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_U8 => AudioCodec::Pcm,
1207        _ => {
1208            log::warn!(
1209                "audio_codec has no mapping, using Unknown \
1210                 codec_id={codec_id}"
1211            );
1212            AudioCodec::Unknown
1213        }
1214    }
1215}
1216
1217/// Maps an `FFmpeg` `AVSampleFormat` to our [`SampleFormat`] enum.
1218fn map_sample_format(format: i32) -> SampleFormat {
1219    #[expect(clippy::cast_sign_loss, reason = "AVSampleFormat values are positive")]
1220    let format_u32 = format as u32;
1221
1222    match format_u32 {
1223        // Packed (interleaved) formats
1224        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8 as u32 => SampleFormat::U8,
1225        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16 as u32 => SampleFormat::I16,
1226        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32 as u32 => SampleFormat::I32,
1227        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLT as u32 => SampleFormat::F32,
1228        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBL as u32 => SampleFormat::F64,
1229        // Planar formats
1230        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8P as u32 => SampleFormat::U8p,
1231        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16P as u32 => SampleFormat::I16p,
1232        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32P as u32 => SampleFormat::I32p,
1233        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLTP as u32 => SampleFormat::F32p,
1234        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBLP as u32 => SampleFormat::F64p,
1235        // Unknown format
1236        _ => {
1237            log::warn!(
1238                "sample_format has no mapping, using Other \
1239                 format={format_u32}"
1240            );
1241            SampleFormat::Other(format_u32)
1242        }
1243    }
1244}
1245
1246// ============================================================================
1247// Chapter Extraction
1248// ============================================================================
1249
1250/// Extracts all chapters from an `AVFormatContext`.
1251///
1252/// # Safety
1253///
1254/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
1255unsafe fn extract_chapters(ctx: *mut ff_sys::AVFormatContext) -> Vec<ChapterInfo> {
1256    // SAFETY: Caller guarantees ctx is valid
1257    unsafe {
1258        let nb_chapters = (*ctx).nb_chapters;
1259        let chapters_ptr = (*ctx).chapters;
1260
1261        if chapters_ptr.is_null() || nb_chapters == 0 {
1262            return Vec::new();
1263        }
1264
1265        let mut chapters = Vec::with_capacity(nb_chapters as usize);
1266
1267        for i in 0..nb_chapters {
1268            // SAFETY: i < nb_chapters, so this is within bounds
1269            let chapter = *chapters_ptr.add(i as usize);
1270            if chapter.is_null() {
1271                continue;
1272            }
1273
1274            chapters.push(extract_single_chapter(chapter));
1275        }
1276
1277        chapters
1278    }
1279}
1280
1281/// Extracts information from a single `AVChapter`.
1282///
1283/// # Safety
1284///
1285/// The `chapter` pointer must be valid.
1286unsafe fn extract_single_chapter(chapter: *mut ff_sys::AVChapter) -> ChapterInfo {
1287    // SAFETY: Caller guarantees chapter is valid
1288    unsafe {
1289        let id = (*chapter).id;
1290
1291        let av_tb = (*chapter).time_base;
1292        let time_base = if av_tb.den != 0 {
1293            Some(Rational::new(av_tb.num, av_tb.den))
1294        } else {
1295            log::warn!(
1296                "chapter time_base has zero denominator, treating as unknown \
1297                 chapter_id={id} time_base_num={num} time_base_den=0",
1298                num = av_tb.num
1299            );
1300            None
1301        };
1302
1303        let (start, end) = if let Some(tb) = time_base {
1304            (
1305                pts_to_duration((*chapter).start, tb),
1306                pts_to_duration((*chapter).end, tb),
1307            )
1308        } else {
1309            (std::time::Duration::ZERO, std::time::Duration::ZERO)
1310        };
1311
1312        let title = extract_chapter_title((*chapter).metadata);
1313        let metadata = extract_chapter_metadata((*chapter).metadata);
1314
1315        let mut builder = ChapterInfo::builder().id(id).start(start).end(end);
1316
1317        if let Some(t) = title {
1318            builder = builder.title(t);
1319        }
1320        if let Some(tb) = time_base {
1321            builder = builder.time_base(tb);
1322        }
1323        if let Some(m) = metadata {
1324            builder = builder.metadata(m);
1325        }
1326
1327        builder.build()
1328    }
1329}
1330
1331/// Converts a PTS value to a [`Duration`] using the given time base.
1332///
1333/// Returns [`Duration::ZERO`] for non-positive PTS values.
1334fn pts_to_duration(pts: i64, time_base: Rational) -> std::time::Duration {
1335    if pts <= 0 {
1336        return std::time::Duration::ZERO;
1337    }
1338    // secs = pts * num / den
1339    // Note: precision loss from i64/i32 to f64 is acceptable for media timestamps
1340    #[expect(clippy::cast_precision_loss, reason = "media timestamps are bounded")]
1341    let secs = (pts as f64) * f64::from(time_base.num()) / f64::from(time_base.den());
1342    if secs > 0.0 {
1343        std::time::Duration::from_secs_f64(secs)
1344    } else {
1345        std::time::Duration::ZERO
1346    }
1347}
1348
1349/// Extracts the "title" metadata tag from a chapter's `AVDictionary`.
1350///
1351/// Returns `None` if the dict is null or the tag is absent.
1352///
1353/// # Safety
1354///
1355/// `dict` may be null (returns `None`) or a valid `AVDictionary` pointer.
1356unsafe fn extract_chapter_title(dict: *mut ff_sys::AVDictionary) -> Option<String> {
1357    // SAFETY: av_dict_get handles null dict by returning null
1358    unsafe {
1359        if dict.is_null() {
1360            return None;
1361        }
1362        let entry = ff_sys::av_dict_get(dict, c"title".as_ptr(), std::ptr::null(), 0);
1363        if entry.is_null() {
1364            return None;
1365        }
1366        let value_ptr = (*entry).value;
1367        if value_ptr.is_null() {
1368            return None;
1369        }
1370        Some(CStr::from_ptr(value_ptr).to_string_lossy().into_owned())
1371    }
1372}
1373
1374/// Extracts all metadata tags except "title" from a chapter's `AVDictionary`.
1375///
1376/// Returns `None` if the dict is null or all tags are filtered out.
1377///
1378/// # Safety
1379///
1380/// `dict` may be null (returns `None`) or a valid `AVDictionary` pointer.
1381unsafe fn extract_chapter_metadata(
1382    dict: *mut ff_sys::AVDictionary,
1383) -> Option<HashMap<String, String>> {
1384    // SAFETY: av_dict_get handles null dict by returning null
1385    unsafe {
1386        if dict.is_null() {
1387            return None;
1388        }
1389
1390        let mut map = HashMap::new();
1391        let mut entry: *const ff_sys::AVDictionaryEntry = std::ptr::null();
1392        let flags = ff_sys::AV_DICT_IGNORE_SUFFIX.cast_signed();
1393
1394        loop {
1395            entry = ff_sys::av_dict_get(dict, c"".as_ptr(), entry, flags);
1396            if entry.is_null() {
1397                break;
1398            }
1399
1400            let key_ptr = (*entry).key;
1401            let value_ptr = (*entry).value;
1402
1403            if key_ptr.is_null() || value_ptr.is_null() {
1404                continue;
1405            }
1406
1407            let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
1408            if key == "title" {
1409                continue;
1410            }
1411            let value = CStr::from_ptr(value_ptr).to_string_lossy().into_owned();
1412            map.insert(key, value);
1413        }
1414
1415        if map.is_empty() { None } else { Some(map) }
1416    }
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421    use super::*;
1422
1423    #[test]
1424    fn test_open_nonexistent_file() {
1425        let result = open("/nonexistent/path/to/video.mp4");
1426        assert!(result.is_err());
1427        match result {
1428            Err(ProbeError::FileNotFound { path }) => {
1429                assert!(path.to_string_lossy().contains("video.mp4"));
1430            }
1431            _ => panic!("Expected FileNotFound error"),
1432        }
1433    }
1434
1435    #[test]
1436    fn test_open_invalid_file() {
1437        // Create a temporary file with invalid content
1438        let temp_dir = std::env::temp_dir();
1439        let temp_file = temp_dir.join("ff_probe_test_invalid.mp4");
1440        std::fs::write(&temp_file, b"not a valid video file").ok();
1441
1442        let result = open(&temp_file);
1443
1444        // Clean up
1445        std::fs::remove_file(&temp_file).ok();
1446
1447        // FFmpeg should fail to open this as a valid media file
1448        assert!(result.is_err());
1449        match result {
1450            Err(ProbeError::CannotOpen { .. }) | Err(ProbeError::InvalidMedia { .. }) => {}
1451            _ => panic!("Expected CannotOpen or InvalidMedia error"),
1452        }
1453    }
1454
1455    #[test]
1456    fn test_av_time_base_constant() {
1457        // Verify our constant matches the expected value
1458        assert_eq!(AV_TIME_BASE, 1_000_000);
1459    }
1460
1461    // ========================================================================
1462    // pts_to_duration Tests
1463    // ========================================================================
1464
1465    #[test]
1466    fn pts_to_duration_should_convert_millisecond_timebase_correctly() {
1467        // 1/1000 timebase: 5000 pts = 5 seconds
1468        let tb = Rational::new(1, 1000);
1469        let dur = pts_to_duration(5000, tb);
1470        assert_eq!(dur, Duration::from_secs(5));
1471    }
1472
1473    #[test]
1474    fn pts_to_duration_should_convert_mpeg_ts_timebase_correctly() {
1475        // 1/90000 timebase: 90000 pts = 1 second
1476        let tb = Rational::new(1, 90000);
1477        let dur = pts_to_duration(90000, tb);
1478        assert!((dur.as_secs_f64() - 1.0).abs() < 1e-6);
1479    }
1480
1481    #[test]
1482    fn pts_to_duration_should_return_zero_for_zero_pts() {
1483        let tb = Rational::new(1, 1000);
1484        assert_eq!(pts_to_duration(0, tb), Duration::ZERO);
1485    }
1486
1487    #[test]
1488    fn pts_to_duration_should_return_zero_for_negative_pts() {
1489        let tb = Rational::new(1, 1000);
1490        assert_eq!(pts_to_duration(-1, tb), Duration::ZERO);
1491    }
1492
1493    #[test]
1494    fn test_duration_conversion() {
1495        // Test duration calculation logic
1496        let duration_us: i64 = 5_500_000; // 5.5 seconds
1497        let secs = (duration_us / AV_TIME_BASE) as u64;
1498        let micros = (duration_us % AV_TIME_BASE) as u32;
1499        let duration = Duration::new(secs, micros * 1000);
1500
1501        assert_eq!(duration.as_secs(), 5);
1502        assert_eq!(duration.subsec_micros(), 500_000);
1503    }
1504
1505    // ========================================================================
1506    // Video Codec Mapping Tests
1507    // ========================================================================
1508
1509    #[test]
1510    fn test_map_video_codec_h264() {
1511        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_H264);
1512        assert_eq!(codec, VideoCodec::H264);
1513    }
1514
1515    #[test]
1516    fn test_map_video_codec_hevc() {
1517        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_HEVC);
1518        assert_eq!(codec, VideoCodec::H265);
1519    }
1520
1521    #[test]
1522    fn test_map_video_codec_vp9() {
1523        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_VP9);
1524        assert_eq!(codec, VideoCodec::Vp9);
1525    }
1526
1527    #[test]
1528    fn test_map_video_codec_av1() {
1529        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_AV1);
1530        assert_eq!(codec, VideoCodec::Av1);
1531    }
1532
1533    #[test]
1534    fn test_map_video_codec_unknown() {
1535        // Use a codec ID that's not explicitly mapped
1536        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_THEORA);
1537        assert_eq!(codec, VideoCodec::Unknown);
1538    }
1539
1540    // ========================================================================
1541    // Pixel Format Mapping Tests
1542    // ========================================================================
1543
1544    #[test]
1545    fn test_map_pixel_format_yuv420p() {
1546        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P as i32);
1547        assert_eq!(format, PixelFormat::Yuv420p);
1548    }
1549
1550    #[test]
1551    fn test_map_pixel_format_rgba() {
1552        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_RGBA as i32);
1553        assert_eq!(format, PixelFormat::Rgba);
1554    }
1555
1556    #[test]
1557    fn test_map_pixel_format_nv12() {
1558        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_NV12 as i32);
1559        assert_eq!(format, PixelFormat::Nv12);
1560    }
1561
1562    #[test]
1563    fn test_map_pixel_format_yuv420p10le() {
1564        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P10LE as i32);
1565        assert_eq!(format, PixelFormat::Yuv420p10le);
1566    }
1567
1568    #[test]
1569    fn test_map_pixel_format_unknown() {
1570        // Use a pixel format that's not explicitly mapped
1571        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_PAL8 as i32);
1572        assert!(matches!(format, PixelFormat::Other(_)));
1573    }
1574
1575    // ========================================================================
1576    // Color Space Mapping Tests
1577    // ========================================================================
1578
1579    #[test]
1580    fn test_map_color_space_bt709() {
1581        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT709);
1582        assert_eq!(space, ColorSpace::Bt709);
1583    }
1584
1585    #[test]
1586    fn test_map_color_space_bt601() {
1587        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT470BG);
1588        assert_eq!(space, ColorSpace::Bt601);
1589
1590        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_SMPTE170M);
1591        assert_eq!(space, ColorSpace::Bt601);
1592    }
1593
1594    #[test]
1595    fn test_map_color_space_bt2020() {
1596        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT2020_NCL);
1597        assert_eq!(space, ColorSpace::Bt2020);
1598
1599        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT2020_CL);
1600        assert_eq!(space, ColorSpace::Bt2020);
1601    }
1602
1603    #[test]
1604    fn test_map_color_space_srgb() {
1605        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_RGB);
1606        assert_eq!(space, ColorSpace::Srgb);
1607    }
1608
1609    #[test]
1610    fn test_map_color_space_unknown() {
1611        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_UNSPECIFIED);
1612        assert_eq!(space, ColorSpace::Unknown);
1613    }
1614
1615    // ========================================================================
1616    // Color Range Mapping Tests
1617    // ========================================================================
1618
1619    #[test]
1620    fn test_map_color_range_limited() {
1621        let range = map_color_range(ff_sys::AVColorRange_AVCOL_RANGE_MPEG);
1622        assert_eq!(range, ColorRange::Limited);
1623    }
1624
1625    #[test]
1626    fn test_map_color_range_full() {
1627        let range = map_color_range(ff_sys::AVColorRange_AVCOL_RANGE_JPEG);
1628        assert_eq!(range, ColorRange::Full);
1629    }
1630
1631    #[test]
1632    fn test_map_color_range_unknown() {
1633        let range = map_color_range(ff_sys::AVColorRange_AVCOL_RANGE_UNSPECIFIED);
1634        assert_eq!(range, ColorRange::Unknown);
1635    }
1636
1637    // ========================================================================
1638    // Color Primaries Mapping Tests
1639    // ========================================================================
1640
1641    #[test]
1642    fn test_map_color_primaries_bt709() {
1643        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_BT709);
1644        assert_eq!(primaries, ColorPrimaries::Bt709);
1645    }
1646
1647    #[test]
1648    fn test_map_color_primaries_bt601() {
1649        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_BT470BG);
1650        assert_eq!(primaries, ColorPrimaries::Bt601);
1651
1652        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_SMPTE170M);
1653        assert_eq!(primaries, ColorPrimaries::Bt601);
1654    }
1655
1656    #[test]
1657    fn test_map_color_primaries_bt2020() {
1658        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_BT2020);
1659        assert_eq!(primaries, ColorPrimaries::Bt2020);
1660    }
1661
1662    #[test]
1663    fn test_map_color_primaries_unknown() {
1664        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_UNSPECIFIED);
1665        assert_eq!(primaries, ColorPrimaries::Unknown);
1666    }
1667
1668    // ========================================================================
1669    // Audio Codec Mapping Tests
1670    // ========================================================================
1671
1672    #[test]
1673    fn test_map_audio_codec_aac() {
1674        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_AAC);
1675        assert_eq!(codec, AudioCodec::Aac);
1676    }
1677
1678    #[test]
1679    fn test_map_audio_codec_mp3() {
1680        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_MP3);
1681        assert_eq!(codec, AudioCodec::Mp3);
1682    }
1683
1684    #[test]
1685    fn test_map_audio_codec_opus() {
1686        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_OPUS);
1687        assert_eq!(codec, AudioCodec::Opus);
1688    }
1689
1690    #[test]
1691    fn test_map_audio_codec_flac() {
1692        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_FLAC);
1693        assert_eq!(codec, AudioCodec::Flac);
1694    }
1695
1696    #[test]
1697    fn test_map_audio_codec_vorbis() {
1698        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_VORBIS);
1699        assert_eq!(codec, AudioCodec::Vorbis);
1700    }
1701
1702    #[test]
1703    fn test_map_audio_codec_ac3() {
1704        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_AC3);
1705        assert_eq!(codec, AudioCodec::Ac3);
1706    }
1707
1708    #[test]
1709    fn test_map_audio_codec_eac3() {
1710        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_EAC3);
1711        assert_eq!(codec, AudioCodec::Eac3);
1712    }
1713
1714    #[test]
1715    fn test_map_audio_codec_dts() {
1716        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_DTS);
1717        assert_eq!(codec, AudioCodec::Dts);
1718    }
1719
1720    #[test]
1721    fn test_map_audio_codec_alac() {
1722        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_ALAC);
1723        assert_eq!(codec, AudioCodec::Alac);
1724    }
1725
1726    #[test]
1727    fn test_map_audio_codec_pcm() {
1728        // Test various PCM formats
1729        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16LE);
1730        assert_eq!(codec, AudioCodec::Pcm);
1731
1732        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_PCM_F32LE);
1733        assert_eq!(codec, AudioCodec::Pcm);
1734
1735        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_PCM_U8);
1736        assert_eq!(codec, AudioCodec::Pcm);
1737    }
1738
1739    #[test]
1740    fn test_map_audio_codec_unknown() {
1741        // Use a codec ID that's not explicitly mapped
1742        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_WMAV2);
1743        assert_eq!(codec, AudioCodec::Unknown);
1744    }
1745
1746    // ========================================================================
1747    // Sample Format Mapping Tests
1748    // ========================================================================
1749
1750    #[test]
1751    fn test_map_sample_format_u8() {
1752        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8 as i32);
1753        assert_eq!(format, SampleFormat::U8);
1754    }
1755
1756    #[test]
1757    fn test_map_sample_format_i16() {
1758        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16 as i32);
1759        assert_eq!(format, SampleFormat::I16);
1760    }
1761
1762    #[test]
1763    fn test_map_sample_format_i32() {
1764        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32 as i32);
1765        assert_eq!(format, SampleFormat::I32);
1766    }
1767
1768    #[test]
1769    fn test_map_sample_format_f32() {
1770        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLT as i32);
1771        assert_eq!(format, SampleFormat::F32);
1772    }
1773
1774    #[test]
1775    fn test_map_sample_format_f64() {
1776        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBL as i32);
1777        assert_eq!(format, SampleFormat::F64);
1778    }
1779
1780    #[test]
1781    fn test_map_sample_format_planar() {
1782        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8P as i32);
1783        assert_eq!(format, SampleFormat::U8p);
1784
1785        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16P as i32);
1786        assert_eq!(format, SampleFormat::I16p);
1787
1788        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32P as i32);
1789        assert_eq!(format, SampleFormat::I32p);
1790
1791        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLTP as i32);
1792        assert_eq!(format, SampleFormat::F32p);
1793
1794        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBLP as i32);
1795        assert_eq!(format, SampleFormat::F64p);
1796    }
1797
1798    #[test]
1799    fn test_map_sample_format_unknown() {
1800        // Use a format value that's not explicitly mapped
1801        let format = map_sample_format(999);
1802        assert!(matches!(format, SampleFormat::Other(_)));
1803    }
1804
1805    // ========================================================================
1806    // Bitrate Calculation Tests
1807    // ========================================================================
1808
1809    #[test]
1810    fn test_bitrate_fallback_calculation() {
1811        // Test the fallback bitrate calculation logic:
1812        // bitrate = file_size (bytes) * 8 (bits/byte) / duration (seconds)
1813        //
1814        // Example: 10 MB file, 10 second duration
1815        // Expected: 10_000_000 bytes * 8 / 10 seconds = 8_000_000 bps
1816        let file_size: u64 = 10_000_000;
1817        let duration = Duration::from_secs(10);
1818        let duration_secs = duration.as_secs_f64();
1819
1820        let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1821        assert_eq!(calculated_bitrate, 8_000_000);
1822    }
1823
1824    #[test]
1825    fn test_bitrate_fallback_with_subsecond_duration() {
1826        // Test with sub-second duration
1827        // 1 MB file, 0.5 second duration
1828        // Expected: 1_000_000 * 8 / 0.5 = 16_000_000 bps
1829        let file_size: u64 = 1_000_000;
1830        let duration = Duration::from_millis(500);
1831        let duration_secs = duration.as_secs_f64();
1832
1833        let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1834        assert_eq!(calculated_bitrate, 16_000_000);
1835    }
1836
1837    #[test]
1838    fn test_bitrate_zero_duration() {
1839        // When duration is zero, we cannot calculate bitrate
1840        let duration = Duration::ZERO;
1841        let duration_secs = duration.as_secs_f64();
1842
1843        // Should not divide when duration is zero
1844        assert!(duration_secs == 0.0);
1845    }
1846
1847    #[test]
1848    fn test_bitrate_zero_file_size() {
1849        // When file size is zero, bitrate should also be zero
1850        let file_size: u64 = 0;
1851        let duration = Duration::from_secs(10);
1852        let duration_secs = duration.as_secs_f64();
1853
1854        if duration_secs > 0.0 && file_size > 0 {
1855            let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1856            assert_eq!(calculated_bitrate, 0);
1857        } else {
1858            // file_size is 0, so we should not have calculated a bitrate
1859            assert_eq!(file_size, 0);
1860        }
1861    }
1862
1863    #[test]
1864    fn test_bitrate_typical_video_file() {
1865        // Test with typical video file parameters:
1866        // 100 MB file, 5 minute duration
1867        // Expected: 100_000_000 * 8 / 300 = 2_666_666 bps (~2.67 Mbps)
1868        let file_size: u64 = 100_000_000;
1869        let duration = Duration::from_secs(300); // 5 minutes
1870        let duration_secs = duration.as_secs_f64();
1871
1872        let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1873        assert_eq!(calculated_bitrate, 2_666_666);
1874    }
1875
1876    #[test]
1877    fn test_bitrate_high_quality_video() {
1878        // Test with high-quality video parameters:
1879        // 5 GB file, 2 hour duration
1880        // Expected: 5_000_000_000 * 8 / 7200 = 5_555_555 bps (~5.6 Mbps)
1881        let file_size: u64 = 5_000_000_000;
1882        let duration = Duration::from_secs(7200); // 2 hours
1883        let duration_secs = duration.as_secs_f64();
1884
1885        let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1886        assert_eq!(calculated_bitrate, 5_555_555);
1887    }
1888}