use crate::core::stream_info::init_format_context;
use crate::error::{ContainerInfoError, Result};
use ffmpeg_sys_next::{av_dict_iterate, AVDictionary, AVDictionaryEntry};
use std::ffi::CStr;
use std::ptr::null;
unsafe fn dict_to_vec(dict: *mut AVDictionary) -> Vec<(String, String)> {
let mut out = Vec::new();
if dict.is_null() {
return out;
}
let mut entry: *const AVDictionaryEntry = null();
while {
entry = av_dict_iterate(dict, entry);
!entry.is_null()
} {
let k = CStr::from_ptr((*entry).key).to_string_lossy().into_owned();
let v = CStr::from_ptr((*entry).value)
.to_string_lossy()
.into_owned();
out.push((k, v));
}
out
}
pub fn get_duration_us(input: impl Into<String>) -> Result<i64> {
let ctx = init_format_context(input)?;
Ok(unsafe { (*ctx.as_ptr()).duration })
}
pub fn get_format(input: impl Into<String>) -> Result<String> {
let ctx = init_format_context(input)?;
unsafe {
let iformat = (*ctx.as_ptr()).iformat;
Ok(CStr::from_ptr((*iformat).name)
.to_string_lossy()
.into_owned())
}
}
pub fn get_metadata(input: impl Into<String>) -> Result<Vec<(String, String)>> {
let ctx = init_format_context(input)?;
Ok(unsafe { dict_to_vec((*ctx.as_ptr()).metadata) })
}
pub fn get_chapter_metadata(
input: impl Into<String>,
chapter_index: usize,
) -> Result<Vec<(String, String)>> {
let ctx = init_format_context(input)?;
unsafe {
let fmt = ctx.as_ptr();
let count = (*fmt).nb_chapters as usize;
if chapter_index >= count {
return Err(ContainerInfoError::ChapterIndexOutOfRange {
index: chapter_index,
count,
}
.into());
}
let chapter = *(*fmt).chapters.add(chapter_index);
Ok(dict_to_vec((*chapter).metadata))
}
}
pub fn get_stream_metadata(
input: impl Into<String>,
stream_index: usize,
) -> Result<Vec<(String, String)>> {
let ctx = init_format_context(input)?;
unsafe {
let fmt = ctx.as_ptr();
let count = (*fmt).nb_streams as usize;
if stream_index >= count {
return Err(ContainerInfoError::StreamIndexOutOfRange {
index: stream_index,
count,
}
.into());
}
let stream = *(*fmt).streams.add(stream_index);
Ok(dict_to_vec((*stream).metadata))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use std::path::Path;
const TEST_VIDEO_PATH: &str = "test.mp4";
fn require_test_asset() {
assert!(
Path::new(TEST_VIDEO_PATH).exists(),
"Expected '{}' to exist in the repo root for container_info tests",
TEST_VIDEO_PATH
);
}
fn write_ffmetadata_with_chapter() -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!(
"ez_ffmpeg_container_info_{}.ffmeta",
std::process::id()
));
let body = ";FFMETADATA1\ntitle=Test Container\n\
[CHAPTER]\nTIMEBASE=1/1000\nSTART=0\nEND=5000\ntitle=Chapter One\n";
std::fs::write(&path, body).expect("write ffmetadata chapter fixture");
path
}
#[test]
fn test_get_chapter_metadata_success_reads_chapter_title() {
let path = write_ffmetadata_with_chapter();
let result = get_chapter_metadata(path.to_string_lossy().as_ref(), 0);
let _ = std::fs::remove_file(&path);
let metadata = result.expect("chapter 0 of the fixture must be readable");
assert!(
metadata
.iter()
.any(|(k, v)| k == "title" && v == "Chapter One"),
"chapter metadata must include the title we wrote: {metadata:?}"
);
}
#[test]
fn test_get_chapter_metadata_out_of_range_on_a_chapterless_file() {
require_test_asset();
let result = get_chapter_metadata(TEST_VIDEO_PATH, 0);
assert!(
matches!(
result,
Err(Error::ContainerInfo(
ContainerInfoError::ChapterIndexOutOfRange { index: 0, count: 0 }
))
),
"got {result:?}"
);
}
#[test]
fn test_get_chapter_metadata_out_of_range_reports_index_and_count() {
require_test_asset();
let result = get_chapter_metadata(TEST_VIDEO_PATH, 999);
assert!(
matches!(
result,
Err(Error::ContainerInfo(
ContainerInfoError::ChapterIndexOutOfRange {
index: 999,
count: 0
}
))
),
"got {result:?}"
);
}
#[test]
fn test_get_stream_metadata_video_stream() {
require_test_asset();
let metadata = get_stream_metadata(TEST_VIDEO_PATH, 0).unwrap();
assert!(!metadata.is_empty());
}
#[test]
fn test_get_stream_metadata_audio_stream() {
require_test_asset();
let metadata = get_stream_metadata(TEST_VIDEO_PATH, 1).unwrap();
assert!(!metadata.is_empty());
}
#[test]
fn test_get_stream_metadata_out_of_range_reports_index_and_count() {
require_test_asset();
match get_stream_metadata(TEST_VIDEO_PATH, 999) {
Err(Error::ContainerInfo(ContainerInfoError::StreamIndexOutOfRange {
index,
count,
})) => {
assert_eq!(index, 999);
assert!(
count >= 2,
"test.mp4 has at least a video and an audio stream, got count={count}"
);
}
other => panic!("expected StreamIndexOutOfRange, got {other:?}"),
}
}
#[test]
fn test_get_metadata_returns_global_entries() {
require_test_asset();
let metadata = get_metadata(TEST_VIDEO_PATH).unwrap();
assert!(!metadata.is_empty());
}
#[test]
fn all_queries_compose_with_the_question_mark_operator() {
fn _compose(path: &str, stream: usize, chapter: usize) -> Result<()> {
get_duration_us(path)?;
get_format(path)?;
get_metadata(path)?;
get_chapter_metadata(path, chapter)?;
get_stream_metadata(path, stream)?;
Ok(())
}
let _ = _compose as fn(&str, usize, usize) -> Result<()>;
}
#[test]
fn test_get_duration_and_format() {
require_test_asset();
let duration = get_duration_us(TEST_VIDEO_PATH).unwrap();
assert!(
duration > 0,
"sample asset should report a positive duration"
);
let format = get_format(TEST_VIDEO_PATH).unwrap();
assert!(!format.is_empty(), "format name must be non-empty");
}
}