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_YUV422P10LE as u32 => PixelFormat::Yuv422p10le,
741        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_P010LE as u32 => PixelFormat::P010le,
742        x if x == ff_sys::AVPixelFormat_AV_PIX_FMT_GRAY8 as u32 => PixelFormat::Gray8,
743        _ => {
744            log::warn!(
745                "pixel_format has no mapping, using Other \
746                 format={format_u32}"
747            );
748            PixelFormat::Other(format_u32)
749        }
750    }
751}
752
753/// Maps an `FFmpeg` `AVColorSpace` to our [`ColorSpace`] enum.
754fn map_color_space(color_space: ff_sys::AVColorSpace) -> ColorSpace {
755    match color_space {
756        ff_sys::AVColorSpace_AVCOL_SPC_BT709 => ColorSpace::Bt709,
757        ff_sys::AVColorSpace_AVCOL_SPC_BT470BG | ff_sys::AVColorSpace_AVCOL_SPC_SMPTE170M => {
758            ColorSpace::Bt601
759        }
760        ff_sys::AVColorSpace_AVCOL_SPC_BT2020_NCL | ff_sys::AVColorSpace_AVCOL_SPC_BT2020_CL => {
761            ColorSpace::Bt2020
762        }
763        ff_sys::AVColorSpace_AVCOL_SPC_RGB => ColorSpace::Srgb,
764        _ => {
765            log::warn!(
766                "color_space has no mapping, using Unknown \
767                 color_space={color_space}"
768            );
769            ColorSpace::Unknown
770        }
771    }
772}
773
774/// Maps an `FFmpeg` `AVColorRange` to our [`ColorRange`] enum.
775fn map_color_range(color_range: ff_sys::AVColorRange) -> ColorRange {
776    match color_range {
777        ff_sys::AVColorRange_AVCOL_RANGE_MPEG => ColorRange::Limited,
778        ff_sys::AVColorRange_AVCOL_RANGE_JPEG => ColorRange::Full,
779        _ => {
780            log::warn!(
781                "color_range has no mapping, using Unknown \
782                 color_range={color_range}"
783            );
784            ColorRange::Unknown
785        }
786    }
787}
788
789/// Maps an `FFmpeg` `AVColorPrimaries` to our [`ColorPrimaries`] enum.
790fn map_color_primaries(color_primaries: ff_sys::AVColorPrimaries) -> ColorPrimaries {
791    match color_primaries {
792        ff_sys::AVColorPrimaries_AVCOL_PRI_BT709 => ColorPrimaries::Bt709,
793        ff_sys::AVColorPrimaries_AVCOL_PRI_BT470BG
794        | ff_sys::AVColorPrimaries_AVCOL_PRI_SMPTE170M => ColorPrimaries::Bt601,
795        ff_sys::AVColorPrimaries_AVCOL_PRI_BT2020 => ColorPrimaries::Bt2020,
796        _ => {
797            log::warn!(
798                "color_primaries has no mapping, using Unknown \
799                 color_primaries={color_primaries}"
800            );
801            ColorPrimaries::Unknown
802        }
803    }
804}
805
806// ============================================================================
807// Audio Stream Extraction
808// ============================================================================
809
810/// Extracts all audio streams from an `AVFormatContext`.
811///
812/// This function iterates through all streams in the container and extracts
813/// detailed information for each audio stream.
814///
815/// # Safety
816///
817/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
818unsafe fn extract_audio_streams(ctx: *mut ff_sys::AVFormatContext) -> Vec<AudioStreamInfo> {
819    // SAFETY: Caller guarantees ctx is valid and find_stream_info was called
820    unsafe {
821        let nb_streams = (*ctx).nb_streams;
822        let streams_ptr = (*ctx).streams;
823
824        if streams_ptr.is_null() || nb_streams == 0 {
825            return Vec::new();
826        }
827
828        let mut audio_streams = Vec::new();
829
830        for i in 0..nb_streams {
831            // SAFETY: i < nb_streams, so this is within bounds
832            let stream = *streams_ptr.add(i as usize);
833            if stream.is_null() {
834                continue;
835            }
836
837            let codecpar = (*stream).codecpar;
838            if codecpar.is_null() {
839                continue;
840            }
841
842            // Check if this is an audio stream
843            if (*codecpar).codec_type != ff_sys::AVMediaType_AVMEDIA_TYPE_AUDIO {
844                continue;
845            }
846
847            // Extract audio stream info
848            let stream_info = extract_single_audio_stream(stream, codecpar, i);
849            audio_streams.push(stream_info);
850        }
851
852        audio_streams
853    }
854}
855
856/// Extracts information from a single audio stream.
857///
858/// # Safety
859///
860/// Both `stream` and `codecpar` pointers must be valid.
861unsafe fn extract_single_audio_stream(
862    stream: *mut ff_sys::AVStream,
863    codecpar: *mut ff_sys::AVCodecParameters,
864    index: u32,
865) -> AudioStreamInfo {
866    // SAFETY: Caller guarantees pointers are valid
867    unsafe {
868        // Extract codec info
869        let codec_id = (*codecpar).codec_id;
870        let codec = map_audio_codec(codec_id);
871        let codec_name = extract_codec_name(codec_id);
872
873        // Extract audio parameters
874        #[expect(clippy::cast_sign_loss, reason = "sample_rate is always positive")]
875        let sample_rate = (*codecpar).sample_rate as u32;
876
877        // FFmpeg 5.1+ uses ch_layout, older versions use channels
878        let channels = extract_channel_count(codecpar);
879
880        // Extract channel layout
881        let channel_layout = extract_channel_layout(codecpar, channels);
882
883        // Extract sample format
884        let sample_format = map_sample_format((*codecpar).format);
885
886        // Extract bitrate
887        let bitrate = extract_stream_bitrate(codecpar);
888
889        // Extract duration if available
890        let duration = extract_stream_duration(stream);
891
892        // Extract language from stream metadata
893        let language = extract_language(stream);
894
895        // Build the AudioStreamInfo
896        let mut builder = AudioStreamInfo::builder()
897            .index(index)
898            .codec(codec)
899            .codec_name(codec_name)
900            .sample_rate(sample_rate)
901            .channels(channels)
902            .channel_layout(channel_layout)
903            .sample_format(sample_format);
904
905        if let Some(d) = duration {
906            builder = builder.duration(d);
907        }
908
909        if let Some(b) = bitrate {
910            builder = builder.bitrate(b);
911        }
912
913        if let Some(lang) = language {
914            builder = builder.language(lang);
915        }
916
917        builder.build()
918    }
919}
920
921/// Extracts the channel count from `AVCodecParameters`.
922///
923/// `FFmpeg` 5.1+ uses `ch_layout.nb_channels`, older versions used `channels` directly.
924///
925/// Returns the actual channel count from `FFmpeg`. If the channel count is 0 (which
926/// indicates uninitialized or unknown), returns 1 (mono) as a safe minimum.
927///
928/// # Safety
929///
930/// The `codecpar` pointer must be valid.
931unsafe fn extract_channel_count(codecpar: *mut ff_sys::AVCodecParameters) -> u32 {
932    // SAFETY: Caller guarantees codecpar is valid
933    // FFmpeg 5.1+ uses ch_layout structure
934    #[expect(clippy::cast_sign_loss, reason = "channel count is always positive")]
935    let channels = unsafe { (*codecpar).ch_layout.nb_channels as u32 };
936
937    // If channel count is 0 (uninitialized/unknown), use 1 (mono) as safe minimum
938    if channels > 0 {
939        channels
940    } else {
941        log::warn!(
942            "channel_count is 0 (uninitialized), falling back to mono \
943             fallback=1"
944        );
945        1
946    }
947}
948
949/// Extracts the channel layout from `AVCodecParameters`.
950///
951/// # Safety
952///
953/// The `codecpar` pointer must be valid.
954unsafe fn extract_channel_layout(
955    codecpar: *mut ff_sys::AVCodecParameters,
956    channels: u32,
957) -> ChannelLayout {
958    // SAFETY: Caller guarantees codecpar is valid
959    // FFmpeg 5.1+ uses ch_layout structure with channel masks
960    let ch_layout = unsafe { &(*codecpar).ch_layout };
961
962    // Check if we have a specific channel layout mask
963    // AV_CHANNEL_ORDER_NATIVE means we have a valid channel mask
964    if ch_layout.order == ff_sys::AVChannelOrder_AV_CHANNEL_ORDER_NATIVE {
965        // Map common FFmpeg channel masks to our ChannelLayout
966        // These are AVChannelLayout masks for standard configurations
967        // SAFETY: When order is AV_CHANNEL_ORDER_NATIVE, the mask field is valid
968        let mask = unsafe { ch_layout.u.mask };
969        match mask {
970            // AV_CH_LAYOUT_MONO = 0x4 (front center)
971            0x4 => ChannelLayout::Mono,
972            // AV_CH_LAYOUT_STEREO = 0x3 (front left + front right)
973            0x3 => ChannelLayout::Stereo,
974            // AV_CH_LAYOUT_2_1 = 0x103 (stereo + LFE)
975            0x103 => ChannelLayout::Stereo2_1,
976            // AV_CH_LAYOUT_SURROUND = 0x7 (FL + FR + FC)
977            0x7 => ChannelLayout::Surround3_0,
978            // AV_CH_LAYOUT_QUAD = 0x33 (FL + FR + BL + BR)
979            0x33 => ChannelLayout::Quad,
980            // AV_CH_LAYOUT_5POINT0 = 0x37 (FL + FR + FC + BL + BR)
981            0x37 => ChannelLayout::Surround5_0,
982            // AV_CH_LAYOUT_5POINT1 = 0x3F (FL + FR + FC + LFE + BL + BR)
983            0x3F => ChannelLayout::Surround5_1,
984            // AV_CH_LAYOUT_6POINT1 = 0x13F (FL + FR + FC + LFE + BC + SL + SR)
985            0x13F => ChannelLayout::Surround6_1,
986            // AV_CH_LAYOUT_7POINT1 = 0x63F (FL + FR + FC + LFE + BL + BR + SL + SR)
987            0x63F => ChannelLayout::Surround7_1,
988            _ => {
989                log::warn!(
990                    "channel_layout mask has no mapping, deriving from channel count \
991                     mask={mask} channels={channels}"
992                );
993                ChannelLayout::from_channels(channels)
994            }
995        }
996    } else {
997        log::warn!(
998            "channel_layout order is not NATIVE, deriving from channel count \
999             order={order} channels={channels}",
1000            order = ch_layout.order
1001        );
1002        ChannelLayout::from_channels(channels)
1003    }
1004}
1005
1006/// Extracts the language tag from stream metadata.
1007///
1008/// # Safety
1009///
1010/// The `stream` pointer must be valid.
1011unsafe fn extract_language(stream: *mut ff_sys::AVStream) -> Option<String> {
1012    // SAFETY: Caller guarantees stream is valid
1013    unsafe {
1014        let metadata = (*stream).metadata;
1015        if metadata.is_null() {
1016            return None;
1017        }
1018
1019        // Look for "language" tag in the stream metadata
1020        let key = c"language";
1021        let entry = ff_sys::av_dict_get(metadata, key.as_ptr(), std::ptr::null(), 0);
1022
1023        if entry.is_null() {
1024            return None;
1025        }
1026
1027        let value_ptr = (*entry).value;
1028        if value_ptr.is_null() {
1029            return None;
1030        }
1031
1032        Some(CStr::from_ptr(value_ptr).to_string_lossy().into_owned())
1033    }
1034}
1035
1036// ============================================================================
1037// Audio Type Mapping Functions
1038// ============================================================================
1039
1040// ============================================================================
1041// Subtitle Stream Extraction
1042// ============================================================================
1043
1044/// Extracts all subtitle streams from an `AVFormatContext`.
1045///
1046/// This function iterates through all streams in the container and extracts
1047/// detailed information for each subtitle stream.
1048///
1049/// # Safety
1050///
1051/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
1052unsafe fn extract_subtitle_streams(ctx: *mut ff_sys::AVFormatContext) -> Vec<SubtitleStreamInfo> {
1053    // SAFETY: Caller guarantees ctx is valid and find_stream_info was called
1054    unsafe {
1055        let nb_streams = (*ctx).nb_streams;
1056        let streams_ptr = (*ctx).streams;
1057
1058        if streams_ptr.is_null() || nb_streams == 0 {
1059            return Vec::new();
1060        }
1061
1062        let mut subtitle_streams = Vec::new();
1063
1064        for i in 0..nb_streams {
1065            // SAFETY: i < nb_streams, so this is within bounds
1066            let stream = *streams_ptr.add(i as usize);
1067            if stream.is_null() {
1068                continue;
1069            }
1070
1071            let codecpar = (*stream).codecpar;
1072            if codecpar.is_null() {
1073                continue;
1074            }
1075
1076            // Check if this is a subtitle stream
1077            if (*codecpar).codec_type != ff_sys::AVMediaType_AVMEDIA_TYPE_SUBTITLE {
1078                continue;
1079            }
1080
1081            let stream_info = extract_single_subtitle_stream(stream, codecpar, i);
1082            subtitle_streams.push(stream_info);
1083        }
1084
1085        subtitle_streams
1086    }
1087}
1088
1089/// Extracts information from a single subtitle stream.
1090///
1091/// # Safety
1092///
1093/// Both `stream` and `codecpar` pointers must be valid.
1094unsafe fn extract_single_subtitle_stream(
1095    stream: *mut ff_sys::AVStream,
1096    codecpar: *mut ff_sys::AVCodecParameters,
1097    index: u32,
1098) -> SubtitleStreamInfo {
1099    // SAFETY: Caller guarantees pointers are valid
1100    unsafe {
1101        let codec_id = (*codecpar).codec_id;
1102        let codec = map_subtitle_codec(codec_id);
1103        let codec_name = extract_codec_name(codec_id);
1104
1105        // disposition is a c_int bitmask; cast to u32 for bitwise AND with the u32 constant
1106        #[expect(
1107            clippy::cast_sign_loss,
1108            reason = "disposition is a non-negative bitmask"
1109        )]
1110        let forced = ((*stream).disposition as u32 & ff_sys::AV_DISPOSITION_FORCED) != 0;
1111
1112        let duration = extract_stream_duration(stream);
1113        let language = extract_language(stream);
1114        let title = extract_stream_title(stream);
1115
1116        let mut builder = SubtitleStreamInfo::builder()
1117            .index(index)
1118            .codec(codec)
1119            .codec_name(codec_name)
1120            .forced(forced);
1121
1122        if let Some(d) = duration {
1123            builder = builder.duration(d);
1124        }
1125        if let Some(lang) = language {
1126            builder = builder.language(lang);
1127        }
1128        if let Some(t) = title {
1129            builder = builder.title(t);
1130        }
1131
1132        builder.build()
1133    }
1134}
1135
1136/// Extracts the "title" metadata tag from a stream's `AVDictionary`.
1137///
1138/// # Safety
1139///
1140/// The `stream` pointer must be valid.
1141unsafe fn extract_stream_title(stream: *mut ff_sys::AVStream) -> Option<String> {
1142    // SAFETY: Caller guarantees stream is valid
1143    unsafe {
1144        let metadata = (*stream).metadata;
1145        if metadata.is_null() {
1146            return None;
1147        }
1148
1149        let key = c"title";
1150        let entry = ff_sys::av_dict_get(metadata, key.as_ptr(), std::ptr::null(), 0);
1151
1152        if entry.is_null() {
1153            return None;
1154        }
1155
1156        let value_ptr = (*entry).value;
1157        if value_ptr.is_null() {
1158            return None;
1159        }
1160
1161        Some(CStr::from_ptr(value_ptr).to_string_lossy().into_owned())
1162    }
1163}
1164
1165/// Maps an `FFmpeg` `AVCodecID` to our [`SubtitleCodec`] enum.
1166fn map_subtitle_codec(codec_id: ff_sys::AVCodecID) -> SubtitleCodec {
1167    match codec_id {
1168        ff_sys::AVCodecID_AV_CODEC_ID_SRT | ff_sys::AVCodecID_AV_CODEC_ID_SUBRIP => {
1169            SubtitleCodec::Srt
1170        }
1171        ff_sys::AVCodecID_AV_CODEC_ID_SSA | ff_sys::AVCodecID_AV_CODEC_ID_ASS => SubtitleCodec::Ass,
1172        ff_sys::AVCodecID_AV_CODEC_ID_DVB_SUBTITLE => SubtitleCodec::Dvb,
1173        ff_sys::AVCodecID_AV_CODEC_ID_HDMV_PGS_SUBTITLE => SubtitleCodec::Hdmv,
1174        ff_sys::AVCodecID_AV_CODEC_ID_WEBVTT => SubtitleCodec::Webvtt,
1175        _ => {
1176            // SAFETY: avcodec_get_name is safe for any codec ID
1177            let name = unsafe { extract_codec_name(codec_id) };
1178            log::warn!("unknown subtitle codec codec_id={codec_id}");
1179            SubtitleCodec::Other(name)
1180        }
1181    }
1182}
1183
1184/// Maps an `FFmpeg` `AVCodecID` to our [`AudioCodec`] enum.
1185fn map_audio_codec(codec_id: ff_sys::AVCodecID) -> AudioCodec {
1186    match codec_id {
1187        ff_sys::AVCodecID_AV_CODEC_ID_AAC => AudioCodec::Aac,
1188        ff_sys::AVCodecID_AV_CODEC_ID_MP3 => AudioCodec::Mp3,
1189        ff_sys::AVCodecID_AV_CODEC_ID_OPUS => AudioCodec::Opus,
1190        ff_sys::AVCodecID_AV_CODEC_ID_FLAC => AudioCodec::Flac,
1191        ff_sys::AVCodecID_AV_CODEC_ID_VORBIS => AudioCodec::Vorbis,
1192        ff_sys::AVCodecID_AV_CODEC_ID_AC3 => AudioCodec::Ac3,
1193        ff_sys::AVCodecID_AV_CODEC_ID_EAC3 => AudioCodec::Eac3,
1194        ff_sys::AVCodecID_AV_CODEC_ID_DTS => AudioCodec::Dts,
1195        ff_sys::AVCodecID_AV_CODEC_ID_ALAC => AudioCodec::Alac,
1196        // PCM variants
1197        ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16LE
1198        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16BE
1199        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S24LE
1200        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S24BE
1201        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S32LE
1202        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_S32BE
1203        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F32LE
1204        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F32BE
1205        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F64LE
1206        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_F64BE
1207        | ff_sys::AVCodecID_AV_CODEC_ID_PCM_U8 => AudioCodec::Pcm,
1208        _ => {
1209            log::warn!(
1210                "audio_codec has no mapping, using Unknown \
1211                 codec_id={codec_id}"
1212            );
1213            AudioCodec::Unknown
1214        }
1215    }
1216}
1217
1218/// Maps an `FFmpeg` `AVSampleFormat` to our [`SampleFormat`] enum.
1219fn map_sample_format(format: i32) -> SampleFormat {
1220    #[expect(clippy::cast_sign_loss, reason = "AVSampleFormat values are positive")]
1221    let format_u32 = format as u32;
1222
1223    match format_u32 {
1224        // Packed (interleaved) formats
1225        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8 as u32 => SampleFormat::U8,
1226        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16 as u32 => SampleFormat::I16,
1227        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32 as u32 => SampleFormat::I32,
1228        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLT as u32 => SampleFormat::F32,
1229        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBL as u32 => SampleFormat::F64,
1230        // Planar formats
1231        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8P as u32 => SampleFormat::U8p,
1232        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16P as u32 => SampleFormat::I16p,
1233        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32P as u32 => SampleFormat::I32p,
1234        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLTP as u32 => SampleFormat::F32p,
1235        x if x == ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBLP as u32 => SampleFormat::F64p,
1236        // Unknown format
1237        _ => {
1238            log::warn!(
1239                "sample_format has no mapping, using Other \
1240                 format={format_u32}"
1241            );
1242            SampleFormat::Other(format_u32)
1243        }
1244    }
1245}
1246
1247// ============================================================================
1248// Chapter Extraction
1249// ============================================================================
1250
1251/// Extracts all chapters from an `AVFormatContext`.
1252///
1253/// # Safety
1254///
1255/// The `ctx` pointer must be valid and `avformat_find_stream_info` must have been called.
1256unsafe fn extract_chapters(ctx: *mut ff_sys::AVFormatContext) -> Vec<ChapterInfo> {
1257    // SAFETY: Caller guarantees ctx is valid
1258    unsafe {
1259        let nb_chapters = (*ctx).nb_chapters;
1260        let chapters_ptr = (*ctx).chapters;
1261
1262        if chapters_ptr.is_null() || nb_chapters == 0 {
1263            return Vec::new();
1264        }
1265
1266        let mut chapters = Vec::with_capacity(nb_chapters as usize);
1267
1268        for i in 0..nb_chapters {
1269            // SAFETY: i < nb_chapters, so this is within bounds
1270            let chapter = *chapters_ptr.add(i as usize);
1271            if chapter.is_null() {
1272                continue;
1273            }
1274
1275            chapters.push(extract_single_chapter(chapter));
1276        }
1277
1278        chapters
1279    }
1280}
1281
1282/// Extracts information from a single `AVChapter`.
1283///
1284/// # Safety
1285///
1286/// The `chapter` pointer must be valid.
1287unsafe fn extract_single_chapter(chapter: *mut ff_sys::AVChapter) -> ChapterInfo {
1288    // SAFETY: Caller guarantees chapter is valid
1289    unsafe {
1290        let id = (*chapter).id;
1291
1292        let av_tb = (*chapter).time_base;
1293        let time_base = if av_tb.den != 0 {
1294            Some(Rational::new(av_tb.num, av_tb.den))
1295        } else {
1296            log::warn!(
1297                "chapter time_base has zero denominator, treating as unknown \
1298                 chapter_id={id} time_base_num={num} time_base_den=0",
1299                num = av_tb.num
1300            );
1301            None
1302        };
1303
1304        let (start, end) = if let Some(tb) = time_base {
1305            (
1306                pts_to_duration((*chapter).start, tb),
1307                pts_to_duration((*chapter).end, tb),
1308            )
1309        } else {
1310            (std::time::Duration::ZERO, std::time::Duration::ZERO)
1311        };
1312
1313        let title = extract_chapter_title((*chapter).metadata);
1314        let metadata = extract_chapter_metadata((*chapter).metadata);
1315
1316        let mut builder = ChapterInfo::builder().id(id).start(start).end(end);
1317
1318        if let Some(t) = title {
1319            builder = builder.title(t);
1320        }
1321        if let Some(tb) = time_base {
1322            builder = builder.time_base(tb);
1323        }
1324        if let Some(m) = metadata {
1325            builder = builder.metadata(m);
1326        }
1327
1328        builder.build()
1329    }
1330}
1331
1332/// Converts a PTS value to a [`Duration`] using the given time base.
1333///
1334/// Returns [`Duration::ZERO`] for non-positive PTS values.
1335fn pts_to_duration(pts: i64, time_base: Rational) -> std::time::Duration {
1336    if pts <= 0 {
1337        return std::time::Duration::ZERO;
1338    }
1339    // secs = pts * num / den
1340    // Note: precision loss from i64/i32 to f64 is acceptable for media timestamps
1341    #[expect(clippy::cast_precision_loss, reason = "media timestamps are bounded")]
1342    let secs = (pts as f64) * f64::from(time_base.num()) / f64::from(time_base.den());
1343    if secs > 0.0 {
1344        std::time::Duration::from_secs_f64(secs)
1345    } else {
1346        std::time::Duration::ZERO
1347    }
1348}
1349
1350/// Extracts the "title" metadata tag from a chapter's `AVDictionary`.
1351///
1352/// Returns `None` if the dict is null or the tag is absent.
1353///
1354/// # Safety
1355///
1356/// `dict` may be null (returns `None`) or a valid `AVDictionary` pointer.
1357unsafe fn extract_chapter_title(dict: *mut ff_sys::AVDictionary) -> Option<String> {
1358    // SAFETY: av_dict_get handles null dict by returning null
1359    unsafe {
1360        if dict.is_null() {
1361            return None;
1362        }
1363        let entry = ff_sys::av_dict_get(dict, c"title".as_ptr(), std::ptr::null(), 0);
1364        if entry.is_null() {
1365            return None;
1366        }
1367        let value_ptr = (*entry).value;
1368        if value_ptr.is_null() {
1369            return None;
1370        }
1371        Some(CStr::from_ptr(value_ptr).to_string_lossy().into_owned())
1372    }
1373}
1374
1375/// Extracts all metadata tags except "title" from a chapter's `AVDictionary`.
1376///
1377/// Returns `None` if the dict is null or all tags are filtered out.
1378///
1379/// # Safety
1380///
1381/// `dict` may be null (returns `None`) or a valid `AVDictionary` pointer.
1382unsafe fn extract_chapter_metadata(
1383    dict: *mut ff_sys::AVDictionary,
1384) -> Option<HashMap<String, String>> {
1385    // SAFETY: av_dict_get handles null dict by returning null
1386    unsafe {
1387        if dict.is_null() {
1388            return None;
1389        }
1390
1391        let mut map = HashMap::new();
1392        let mut entry: *const ff_sys::AVDictionaryEntry = std::ptr::null();
1393        let flags = ff_sys::AV_DICT_IGNORE_SUFFIX.cast_signed();
1394
1395        loop {
1396            entry = ff_sys::av_dict_get(dict, c"".as_ptr(), entry, flags);
1397            if entry.is_null() {
1398                break;
1399            }
1400
1401            let key_ptr = (*entry).key;
1402            let value_ptr = (*entry).value;
1403
1404            if key_ptr.is_null() || value_ptr.is_null() {
1405                continue;
1406            }
1407
1408            let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned();
1409            if key == "title" {
1410                continue;
1411            }
1412            let value = CStr::from_ptr(value_ptr).to_string_lossy().into_owned();
1413            map.insert(key, value);
1414        }
1415
1416        if map.is_empty() { None } else { Some(map) }
1417    }
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422    use super::*;
1423
1424    #[test]
1425    fn test_open_nonexistent_file() {
1426        let result = open("/nonexistent/path/to/video.mp4");
1427        assert!(result.is_err());
1428        match result {
1429            Err(ProbeError::FileNotFound { path }) => {
1430                assert!(path.to_string_lossy().contains("video.mp4"));
1431            }
1432            _ => panic!("Expected FileNotFound error"),
1433        }
1434    }
1435
1436    #[test]
1437    fn test_open_invalid_file() {
1438        // Create a temporary file with invalid content
1439        let temp_dir = std::env::temp_dir();
1440        let temp_file = temp_dir.join("ff_probe_test_invalid.mp4");
1441        std::fs::write(&temp_file, b"not a valid video file").ok();
1442
1443        let result = open(&temp_file);
1444
1445        // Clean up
1446        std::fs::remove_file(&temp_file).ok();
1447
1448        // FFmpeg should fail to open this as a valid media file
1449        assert!(result.is_err());
1450        match result {
1451            Err(ProbeError::CannotOpen { .. }) | Err(ProbeError::InvalidMedia { .. }) => {}
1452            _ => panic!("Expected CannotOpen or InvalidMedia error"),
1453        }
1454    }
1455
1456    #[test]
1457    fn test_av_time_base_constant() {
1458        // Verify our constant matches the expected value
1459        assert_eq!(AV_TIME_BASE, 1_000_000);
1460    }
1461
1462    // ========================================================================
1463    // pts_to_duration Tests
1464    // ========================================================================
1465
1466    #[test]
1467    fn pts_to_duration_should_convert_millisecond_timebase_correctly() {
1468        // 1/1000 timebase: 5000 pts = 5 seconds
1469        let tb = Rational::new(1, 1000);
1470        let dur = pts_to_duration(5000, tb);
1471        assert_eq!(dur, Duration::from_secs(5));
1472    }
1473
1474    #[test]
1475    fn pts_to_duration_should_convert_mpeg_ts_timebase_correctly() {
1476        // 1/90000 timebase: 90000 pts = 1 second
1477        let tb = Rational::new(1, 90000);
1478        let dur = pts_to_duration(90000, tb);
1479        assert!((dur.as_secs_f64() - 1.0).abs() < 1e-6);
1480    }
1481
1482    #[test]
1483    fn pts_to_duration_should_return_zero_for_zero_pts() {
1484        let tb = Rational::new(1, 1000);
1485        assert_eq!(pts_to_duration(0, tb), Duration::ZERO);
1486    }
1487
1488    #[test]
1489    fn pts_to_duration_should_return_zero_for_negative_pts() {
1490        let tb = Rational::new(1, 1000);
1491        assert_eq!(pts_to_duration(-1, tb), Duration::ZERO);
1492    }
1493
1494    #[test]
1495    fn test_duration_conversion() {
1496        // Test duration calculation logic
1497        let duration_us: i64 = 5_500_000; // 5.5 seconds
1498        let secs = (duration_us / AV_TIME_BASE) as u64;
1499        let micros = (duration_us % AV_TIME_BASE) as u32;
1500        let duration = Duration::new(secs, micros * 1000);
1501
1502        assert_eq!(duration.as_secs(), 5);
1503        assert_eq!(duration.subsec_micros(), 500_000);
1504    }
1505
1506    // ========================================================================
1507    // Video Codec Mapping Tests
1508    // ========================================================================
1509
1510    #[test]
1511    fn test_map_video_codec_h264() {
1512        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_H264);
1513        assert_eq!(codec, VideoCodec::H264);
1514    }
1515
1516    #[test]
1517    fn test_map_video_codec_hevc() {
1518        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_HEVC);
1519        assert_eq!(codec, VideoCodec::H265);
1520    }
1521
1522    #[test]
1523    fn test_map_video_codec_vp9() {
1524        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_VP9);
1525        assert_eq!(codec, VideoCodec::Vp9);
1526    }
1527
1528    #[test]
1529    fn test_map_video_codec_av1() {
1530        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_AV1);
1531        assert_eq!(codec, VideoCodec::Av1);
1532    }
1533
1534    #[test]
1535    fn test_map_video_codec_unknown() {
1536        // Use a codec ID that's not explicitly mapped
1537        let codec = map_video_codec(ff_sys::AVCodecID_AV_CODEC_ID_THEORA);
1538        assert_eq!(codec, VideoCodec::Unknown);
1539    }
1540
1541    // ========================================================================
1542    // Pixel Format Mapping Tests
1543    // ========================================================================
1544
1545    #[test]
1546    fn test_map_pixel_format_yuv420p() {
1547        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P as i32);
1548        assert_eq!(format, PixelFormat::Yuv420p);
1549    }
1550
1551    #[test]
1552    fn test_map_pixel_format_rgba() {
1553        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_RGBA as i32);
1554        assert_eq!(format, PixelFormat::Rgba);
1555    }
1556
1557    #[test]
1558    fn test_map_pixel_format_nv12() {
1559        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_NV12 as i32);
1560        assert_eq!(format, PixelFormat::Nv12);
1561    }
1562
1563    #[test]
1564    fn test_map_pixel_format_yuv420p10le() {
1565        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_YUV420P10LE as i32);
1566        assert_eq!(format, PixelFormat::Yuv420p10le);
1567    }
1568
1569    #[test]
1570    fn test_map_pixel_format_unknown() {
1571        // Use a pixel format that's not explicitly mapped
1572        let format = map_pixel_format(ff_sys::AVPixelFormat_AV_PIX_FMT_PAL8 as i32);
1573        assert!(matches!(format, PixelFormat::Other(_)));
1574    }
1575
1576    // ========================================================================
1577    // Color Space Mapping Tests
1578    // ========================================================================
1579
1580    #[test]
1581    fn test_map_color_space_bt709() {
1582        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT709);
1583        assert_eq!(space, ColorSpace::Bt709);
1584    }
1585
1586    #[test]
1587    fn test_map_color_space_bt601() {
1588        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT470BG);
1589        assert_eq!(space, ColorSpace::Bt601);
1590
1591        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_SMPTE170M);
1592        assert_eq!(space, ColorSpace::Bt601);
1593    }
1594
1595    #[test]
1596    fn test_map_color_space_bt2020() {
1597        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT2020_NCL);
1598        assert_eq!(space, ColorSpace::Bt2020);
1599
1600        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_BT2020_CL);
1601        assert_eq!(space, ColorSpace::Bt2020);
1602    }
1603
1604    #[test]
1605    fn test_map_color_space_srgb() {
1606        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_RGB);
1607        assert_eq!(space, ColorSpace::Srgb);
1608    }
1609
1610    #[test]
1611    fn test_map_color_space_unknown() {
1612        let space = map_color_space(ff_sys::AVColorSpace_AVCOL_SPC_UNSPECIFIED);
1613        assert_eq!(space, ColorSpace::Unknown);
1614    }
1615
1616    // ========================================================================
1617    // Color Range Mapping Tests
1618    // ========================================================================
1619
1620    #[test]
1621    fn test_map_color_range_limited() {
1622        let range = map_color_range(ff_sys::AVColorRange_AVCOL_RANGE_MPEG);
1623        assert_eq!(range, ColorRange::Limited);
1624    }
1625
1626    #[test]
1627    fn test_map_color_range_full() {
1628        let range = map_color_range(ff_sys::AVColorRange_AVCOL_RANGE_JPEG);
1629        assert_eq!(range, ColorRange::Full);
1630    }
1631
1632    #[test]
1633    fn test_map_color_range_unknown() {
1634        let range = map_color_range(ff_sys::AVColorRange_AVCOL_RANGE_UNSPECIFIED);
1635        assert_eq!(range, ColorRange::Unknown);
1636    }
1637
1638    // ========================================================================
1639    // Color Primaries Mapping Tests
1640    // ========================================================================
1641
1642    #[test]
1643    fn test_map_color_primaries_bt709() {
1644        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_BT709);
1645        assert_eq!(primaries, ColorPrimaries::Bt709);
1646    }
1647
1648    #[test]
1649    fn test_map_color_primaries_bt601() {
1650        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_BT470BG);
1651        assert_eq!(primaries, ColorPrimaries::Bt601);
1652
1653        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_SMPTE170M);
1654        assert_eq!(primaries, ColorPrimaries::Bt601);
1655    }
1656
1657    #[test]
1658    fn test_map_color_primaries_bt2020() {
1659        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_BT2020);
1660        assert_eq!(primaries, ColorPrimaries::Bt2020);
1661    }
1662
1663    #[test]
1664    fn test_map_color_primaries_unknown() {
1665        let primaries = map_color_primaries(ff_sys::AVColorPrimaries_AVCOL_PRI_UNSPECIFIED);
1666        assert_eq!(primaries, ColorPrimaries::Unknown);
1667    }
1668
1669    // ========================================================================
1670    // Audio Codec Mapping Tests
1671    // ========================================================================
1672
1673    #[test]
1674    fn test_map_audio_codec_aac() {
1675        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_AAC);
1676        assert_eq!(codec, AudioCodec::Aac);
1677    }
1678
1679    #[test]
1680    fn test_map_audio_codec_mp3() {
1681        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_MP3);
1682        assert_eq!(codec, AudioCodec::Mp3);
1683    }
1684
1685    #[test]
1686    fn test_map_audio_codec_opus() {
1687        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_OPUS);
1688        assert_eq!(codec, AudioCodec::Opus);
1689    }
1690
1691    #[test]
1692    fn test_map_audio_codec_flac() {
1693        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_FLAC);
1694        assert_eq!(codec, AudioCodec::Flac);
1695    }
1696
1697    #[test]
1698    fn test_map_audio_codec_vorbis() {
1699        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_VORBIS);
1700        assert_eq!(codec, AudioCodec::Vorbis);
1701    }
1702
1703    #[test]
1704    fn test_map_audio_codec_ac3() {
1705        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_AC3);
1706        assert_eq!(codec, AudioCodec::Ac3);
1707    }
1708
1709    #[test]
1710    fn test_map_audio_codec_eac3() {
1711        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_EAC3);
1712        assert_eq!(codec, AudioCodec::Eac3);
1713    }
1714
1715    #[test]
1716    fn test_map_audio_codec_dts() {
1717        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_DTS);
1718        assert_eq!(codec, AudioCodec::Dts);
1719    }
1720
1721    #[test]
1722    fn test_map_audio_codec_alac() {
1723        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_ALAC);
1724        assert_eq!(codec, AudioCodec::Alac);
1725    }
1726
1727    #[test]
1728    fn test_map_audio_codec_pcm() {
1729        // Test various PCM formats
1730        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16LE);
1731        assert_eq!(codec, AudioCodec::Pcm);
1732
1733        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_PCM_F32LE);
1734        assert_eq!(codec, AudioCodec::Pcm);
1735
1736        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_PCM_U8);
1737        assert_eq!(codec, AudioCodec::Pcm);
1738    }
1739
1740    #[test]
1741    fn test_map_audio_codec_unknown() {
1742        // Use a codec ID that's not explicitly mapped
1743        let codec = map_audio_codec(ff_sys::AVCodecID_AV_CODEC_ID_WMAV2);
1744        assert_eq!(codec, AudioCodec::Unknown);
1745    }
1746
1747    // ========================================================================
1748    // Sample Format Mapping Tests
1749    // ========================================================================
1750
1751    #[test]
1752    fn test_map_sample_format_u8() {
1753        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8 as i32);
1754        assert_eq!(format, SampleFormat::U8);
1755    }
1756
1757    #[test]
1758    fn test_map_sample_format_i16() {
1759        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16 as i32);
1760        assert_eq!(format, SampleFormat::I16);
1761    }
1762
1763    #[test]
1764    fn test_map_sample_format_i32() {
1765        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32 as i32);
1766        assert_eq!(format, SampleFormat::I32);
1767    }
1768
1769    #[test]
1770    fn test_map_sample_format_f32() {
1771        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLT as i32);
1772        assert_eq!(format, SampleFormat::F32);
1773    }
1774
1775    #[test]
1776    fn test_map_sample_format_f64() {
1777        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBL as i32);
1778        assert_eq!(format, SampleFormat::F64);
1779    }
1780
1781    #[test]
1782    fn test_map_sample_format_planar() {
1783        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_U8P as i32);
1784        assert_eq!(format, SampleFormat::U8p);
1785
1786        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S16P as i32);
1787        assert_eq!(format, SampleFormat::I16p);
1788
1789        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_S32P as i32);
1790        assert_eq!(format, SampleFormat::I32p);
1791
1792        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_FLTP as i32);
1793        assert_eq!(format, SampleFormat::F32p);
1794
1795        let format = map_sample_format(ff_sys::AVSampleFormat_AV_SAMPLE_FMT_DBLP as i32);
1796        assert_eq!(format, SampleFormat::F64p);
1797    }
1798
1799    #[test]
1800    fn test_map_sample_format_unknown() {
1801        // Use a format value that's not explicitly mapped
1802        let format = map_sample_format(999);
1803        assert!(matches!(format, SampleFormat::Other(_)));
1804    }
1805
1806    // ========================================================================
1807    // Bitrate Calculation Tests
1808    // ========================================================================
1809
1810    #[test]
1811    fn test_bitrate_fallback_calculation() {
1812        // Test the fallback bitrate calculation logic:
1813        // bitrate = file_size (bytes) * 8 (bits/byte) / duration (seconds)
1814        //
1815        // Example: 10 MB file, 10 second duration
1816        // Expected: 10_000_000 bytes * 8 / 10 seconds = 8_000_000 bps
1817        let file_size: u64 = 10_000_000;
1818        let duration = Duration::from_secs(10);
1819        let duration_secs = duration.as_secs_f64();
1820
1821        let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1822        assert_eq!(calculated_bitrate, 8_000_000);
1823    }
1824
1825    #[test]
1826    fn test_bitrate_fallback_with_subsecond_duration() {
1827        // Test with sub-second duration
1828        // 1 MB file, 0.5 second duration
1829        // Expected: 1_000_000 * 8 / 0.5 = 16_000_000 bps
1830        let file_size: u64 = 1_000_000;
1831        let duration = Duration::from_millis(500);
1832        let duration_secs = duration.as_secs_f64();
1833
1834        let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1835        assert_eq!(calculated_bitrate, 16_000_000);
1836    }
1837
1838    #[test]
1839    fn test_bitrate_zero_duration() {
1840        // When duration is zero, we cannot calculate bitrate
1841        let duration = Duration::ZERO;
1842        let duration_secs = duration.as_secs_f64();
1843
1844        // Should not divide when duration is zero
1845        assert!(duration_secs == 0.0);
1846    }
1847
1848    #[test]
1849    fn test_bitrate_zero_file_size() {
1850        // When file size is zero, bitrate should also be zero
1851        let file_size: u64 = 0;
1852        let duration = Duration::from_secs(10);
1853        let duration_secs = duration.as_secs_f64();
1854
1855        if duration_secs > 0.0 && file_size > 0 {
1856            let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1857            assert_eq!(calculated_bitrate, 0);
1858        } else {
1859            // file_size is 0, so we should not have calculated a bitrate
1860            assert_eq!(file_size, 0);
1861        }
1862    }
1863
1864    #[test]
1865    fn test_bitrate_typical_video_file() {
1866        // Test with typical video file parameters:
1867        // 100 MB file, 5 minute duration
1868        // Expected: 100_000_000 * 8 / 300 = 2_666_666 bps (~2.67 Mbps)
1869        let file_size: u64 = 100_000_000;
1870        let duration = Duration::from_secs(300); // 5 minutes
1871        let duration_secs = duration.as_secs_f64();
1872
1873        let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1874        assert_eq!(calculated_bitrate, 2_666_666);
1875    }
1876
1877    #[test]
1878    fn test_bitrate_high_quality_video() {
1879        // Test with high-quality video parameters:
1880        // 5 GB file, 2 hour duration
1881        // Expected: 5_000_000_000 * 8 / 7200 = 5_555_555 bps (~5.6 Mbps)
1882        let file_size: u64 = 5_000_000_000;
1883        let duration = Duration::from_secs(7200); // 2 hours
1884        let duration_secs = duration.as_secs_f64();
1885
1886        let calculated_bitrate = (file_size as f64 * 8.0 / duration_secs) as u64;
1887        assert_eq!(calculated_bitrate, 5_555_555);
1888    }
1889}