use crate::errors::MetadataError;
use ffmpeg_next as ffmpeg;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MetadataType {
Cover,
Chapters,
Captions,
}
#[derive(Debug, Clone)]
pub struct VideoMetadata {
pub has_chapters: bool,
pub has_subtitles: bool,
pub duration_secs: f64,
}
const VIDEO_EXTENSIONS: &[&str] = &[
"mp4", "m4v", "mov", "avi", "mkv", "webm", "wmv", "flv", "3gp", "ogv", "mpeg", "mpg", "ts",
"mts", "m2ts", "vob", "divx", "xvid", "asf", "rm", "rmvb", "f4v",
];
pub fn has_video_extension(path: &str) -> bool {
let path_lower = path.to_lowercase();
VIDEO_EXTENSIONS
.iter()
.any(|ext| path_lower.ends_with(&format!(".{}", ext)))
}
const WEBKIT_RISKY_DATA_TAGS: &[&str] = &["gpmd"];
const WEBKIT_RISKY_SUBTITLE_TAGS: &[&str] = &["tx3g"];
pub const REMUX_REMEDY: &str = "ffmpeg -i in.mp4 -map 0 -c copy -dn -movflags +faststart out.mp4";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackKind {
Data,
Subtitle,
Other,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackDescriptor {
pub kind: TrackKind,
pub codec_tag: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PlaybackCompatibility {
pub risky_data_tags: Vec<String>,
pub risky_subtitle_tags: Vec<String>,
}
fn describe_tags(tags: &[String], noun: &str) -> String {
match tags {
[tag] => format!("a '{tag}' {noun}"),
tags => format!("'{}' {noun}s", tags.join("', '")),
}
}
impl PlaybackCompatibility {
#[must_use]
pub fn has_known_risk(&self) -> bool {
!self.risky_data_tags.is_empty() && !self.risky_subtitle_tags.is_empty()
}
#[must_use]
pub fn reason(&self) -> Option<String> {
self.has_known_risk().then(|| {
format!(
"This file carries both {} and {}. Safari/WebKit sometimes fails to decode \
that combination, so it is the most likely cause. Files with this \
combination do not always fail, and other browsers are usually unaffected.",
describe_tags(&self.risky_data_tags, "timed-metadata track"),
describe_tags(&self.risky_subtitle_tags, "subtitle track"),
)
})
}
#[must_use]
pub fn remedy(&self) -> Option<String> {
self.has_known_risk().then(|| REMUX_REMEDY.to_string())
}
}
#[must_use]
pub fn assess_playback_compatibility(tracks: &[TrackDescriptor]) -> PlaybackCompatibility {
use itertools::Itertools;
let tags_of = |kind: TrackKind, allowed: &[&str]| -> Vec<String> {
tracks
.iter()
.filter(|track| track.kind == kind)
.filter_map(|track| track.codec_tag.clone())
.filter(|tag| allowed.contains(&tag.as_str()))
.unique()
.collect()
};
PlaybackCompatibility {
risky_data_tags: tags_of(TrackKind::Data, WEBKIT_RISKY_DATA_TAGS),
risky_subtitle_tags: tags_of(TrackKind::Subtitle, WEBKIT_RISKY_SUBTITLE_TAGS),
}
}
fn fourcc_to_string(tag: u32) -> Option<String> {
let bytes = tag.to_le_bytes();
bytes
.iter()
.all(|b| b.is_ascii_graphic())
.then(|| String::from_utf8_lossy(&bytes).into_owned())
}
fn stream_codec_tag(parameters: &ffmpeg::codec::Parameters) -> Option<String> {
let tag = unsafe { (*parameters.as_ptr()).codec_tag };
fourcc_to_string(tag)
}
pub fn probe_playback_compatibility(
video_path: &Path,
) -> Result<PlaybackCompatibility, MetadataError> {
let input = ffmpeg::format::input(video_path).map_err(|e| MetadataError::OpenFailed {
path: video_path.to_path_buf(),
source: e,
})?;
let tracks: Vec<TrackDescriptor> = input
.streams()
.map(|stream| {
let parameters = stream.parameters();
TrackDescriptor {
kind: match parameters.medium() {
ffmpeg::media::Type::Data => TrackKind::Data,
ffmpeg::media::Type::Subtitle => TrackKind::Subtitle,
_ => TrackKind::Other,
},
codec_tag: stream_codec_tag(¶meters),
}
})
.collect();
Ok(assess_playback_compatibility(&tracks))
}
pub fn parse_metadata_request(path: &str) -> Option<(&str, MetadataType)> {
if let Some(video_path) = path.strip_suffix(".cover.jpg")
&& has_video_extension(video_path)
{
return Some((video_path, MetadataType::Cover));
}
if let Some(video_path) = path.strip_suffix(".chapters.en.vtt")
&& has_video_extension(video_path)
{
return Some((video_path, MetadataType::Chapters));
}
if let Some(video_path) = path.strip_suffix(".captions.en.vtt")
&& has_video_extension(video_path)
{
return Some((video_path, MetadataType::Captions));
}
None
}
pub fn probe_video(video_path: &Path) -> Result<VideoMetadata, MetadataError> {
let input = ffmpeg::format::input(video_path).map_err(|e| MetadataError::OpenFailed {
path: video_path.to_path_buf(),
source: e,
})?;
let duration_secs = if input.duration() >= 0 {
input.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE)
} else {
0.0
};
let has_chapters = input.chapters().len() > 0;
let has_subtitles = input
.streams()
.any(|s| s.parameters().medium() == ffmpeg::media::Type::Subtitle);
Ok(VideoMetadata {
has_chapters,
has_subtitles,
duration_secs,
})
}
fn extract_attached_pic(
input: &mut ffmpeg::format::context::Input,
) -> Result<Option<Vec<u8>>, MetadataError> {
use image::codecs::jpeg::JpegEncoder;
let attached_pic_stream = input.streams().find(|s| {
s.disposition()
.contains(ffmpeg::format::stream::Disposition::ATTACHED_PIC)
});
let stream = match attached_pic_stream {
Some(s) => s,
None => return Ok(None),
};
let stream_index = stream.index();
let codec_id = stream.parameters().id();
tracing::debug!(
"Found attached_pic stream {} with codec {:?}",
stream_index,
codec_id
);
for (pkt_stream, packet) in input.packets() {
if pkt_stream.index() != stream_index {
continue;
}
let data = packet.data().ok_or_else(|| {
MetadataError::DecodeFailed("Attached pic packet has no data".to_string())
})?;
if data.len() >= 2 && data[0] == 0xFF && data[1] == 0xD8 {
tracing::debug!("Attached pic is already JPEG ({} bytes)", data.len());
return Ok(Some(data.to_vec()));
}
if data.len() >= 8 && &data[0..8] == b"\x89PNG\r\n\x1a\n" {
tracing::debug!("Attached pic is PNG, converting to JPEG");
let img = image::load_from_memory(data).map_err(|e| {
MetadataError::DecodeFailed(format!("Failed to decode attached PNG: {}", e))
})?;
let mut jpg_data = Vec::new();
let encoder =
JpegEncoder::new_with_quality(&mut jpg_data, crate::constants::JPEG_QUALITY);
img.write_with_encoder(encoder).map_err(|e| {
MetadataError::EncodeFailed(format!("Failed to encode JPEG: {}", e))
})?;
return Ok(Some(jpg_data));
}
tracing::debug!(
"Attached pic has unknown format (first bytes: {:02x?}), trying image crate",
&data[..std::cmp::min(16, data.len())]
);
match image::load_from_memory(data) {
Ok(img) => {
let mut jpg_data = Vec::new();
let encoder =
JpegEncoder::new_with_quality(&mut jpg_data, crate::constants::JPEG_QUALITY);
img.write_with_encoder(encoder).map_err(|e| {
MetadataError::EncodeFailed(format!("Failed to encode JPEG: {}", e))
})?;
return Ok(Some(jpg_data));
}
Err(e) => {
tracing::warn!(
"Failed to decode attached pic: {}, falling back to frame capture",
e
);
return Ok(None);
}
}
}
Ok(None)
}
pub fn extract_cover(video_path: &Path) -> Result<Vec<u8>, MetadataError> {
let mut input = ffmpeg::format::input(video_path).map_err(|e| MetadataError::OpenFailed {
path: video_path.to_path_buf(),
source: e,
})?;
if let Some(cover_data) = extract_attached_pic(&mut input)? {
tracing::debug!("Using embedded thumbnail from video");
return Ok(cover_data);
}
tracing::debug!("No embedded thumbnail, capturing frame from video");
let video_stream_index = input
.streams()
.best(ffmpeg::media::Type::Video)
.ok_or_else(|| MetadataError::NoVideoStream {
path: video_path.to_path_buf(),
})?
.index();
let stream = input
.stream(video_stream_index)
.ok_or_else(|| MetadataError::NoVideoStream {
path: video_path.to_path_buf(),
})?;
let time_base = stream.time_base();
let codec_params = stream.parameters();
let duration_secs = if input.duration() >= 0 {
input.duration() as f64 / f64::from(ffmpeg::ffi::AV_TIME_BASE)
} else {
let stream_duration = stream.duration();
if stream_duration > 0 {
stream_duration as f64 * f64::from(time_base.numerator())
/ f64::from(time_base.denominator())
} else {
0.0
}
};
let target_secs = if duration_secs >= 5.0 {
5.0
} else if duration_secs >= 1.0 {
duration_secs * 0.5
} else if duration_secs > 0.0 {
0.0
} else {
return Err(MetadataError::VideoTooShort { duration_secs: 0.0 });
};
let target_ts = (target_secs * f64::from(time_base.denominator())
/ f64::from(time_base.numerator())) as i64;
input
.seek(target_ts, target_ts..)
.map_err(|e| MetadataError::DecodeFailed(format!("Seek failed: {}", e)))?;
let context = ffmpeg::codec::context::Context::from_parameters(codec_params).map_err(|e| {
MetadataError::DecodeFailed(format!("Failed to create codec context: {}", e))
})?;
let mut decoder = context
.decoder()
.video()
.map_err(|e| MetadataError::DecodeFailed(format!("Failed to create decoder: {}", e)))?;
let mut frame = ffmpeg::frame::Video::empty();
for (stream, packet) in input.packets() {
if stream.index() == video_stream_index {
decoder
.send_packet(&packet)
.map_err(|e| MetadataError::DecodeFailed(format!("Send packet failed: {}", e)))?;
if decoder.receive_frame(&mut frame).is_ok() {
return frame_to_jpg(&frame, decoder.width(), decoder.height());
}
}
}
decoder
.send_eof()
.map_err(|e| MetadataError::DecodeFailed(format!("Send EOF failed: {}", e)))?;
if decoder.receive_frame(&mut frame).is_ok() {
return frame_to_jpg(&frame, decoder.width(), decoder.height());
}
Err(MetadataError::DecodeFailed(
"No frames could be decoded".to_string(),
))
}
fn frame_to_jpg(
frame: &ffmpeg::frame::Video,
width: u32,
height: u32,
) -> Result<Vec<u8>, MetadataError> {
use image::codecs::jpeg::JpegEncoder;
let mut scaler = ffmpeg::software::scaling::Context::get(
frame.format(),
width,
height,
ffmpeg::format::Pixel::RGB24,
width,
height,
ffmpeg::software::scaling::Flags::BILINEAR,
)
.map_err(|e| MetadataError::EncodeFailed(format!("Failed to create scaler: {}", e)))?;
let mut rgb_frame = ffmpeg::frame::Video::empty();
scaler
.run(frame, &mut rgb_frame)
.map_err(|e| MetadataError::EncodeFailed(format!("Failed to scale frame: {}", e)))?;
let data = rgb_frame.data(0);
let stride = rgb_frame.stride(0);
let mut rgb_data = Vec::with_capacity((width * height * 3) as usize);
for y in 0..height as usize {
let row_start = y * stride;
let row_end = row_start + (width as usize * 3);
rgb_data.extend_from_slice(&data[row_start..row_end]);
}
let img: image::ImageBuffer<image::Rgb<u8>, Vec<u8>> =
image::ImageBuffer::from_raw(width, height, rgb_data).ok_or_else(|| {
MetadataError::EncodeFailed("Failed to create image buffer".to_string())
})?;
let mut jpg_data = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut jpg_data, crate::constants::JPEG_QUALITY);
img.write_with_encoder(encoder)
.map_err(|e| MetadataError::EncodeFailed(format!("Failed to encode JPEG: {}", e)))?;
Ok(jpg_data)
}
pub fn extract_chapters(video_path: &Path) -> Result<String, MetadataError> {
let input = ffmpeg::format::input(video_path).map_err(|e| MetadataError::OpenFailed {
path: video_path.to_path_buf(),
source: e,
})?;
let chapters: Vec<_> = input.chapters().collect();
if chapters.is_empty() {
return Err(MetadataError::NoChapters {
path: video_path.to_path_buf(),
});
}
let mut vtt = String::from("WEBVTT\n\n");
for chapter in chapters {
let time_base = chapter.time_base();
let start_secs = chapter.start() as f64 * f64::from(time_base.numerator())
/ f64::from(time_base.denominator());
let end_secs = chapter.end() as f64 * f64::from(time_base.numerator())
/ f64::from(time_base.denominator());
let title = chapter
.metadata()
.get("title")
.map(|s| s.to_string())
.unwrap_or_else(|| "Untitled".to_string());
vtt.push_str(&format!(
"{} --> {}\n{}\n\n",
format_vtt_time(start_secs),
format_vtt_time(end_secs),
title
));
}
Ok(vtt)
}
pub fn extract_captions(video_path: &Path) -> Result<String, MetadataError> {
let mut input = ffmpeg::format::input(video_path).map_err(|e| MetadataError::OpenFailed {
path: video_path.to_path_buf(),
source: e,
})?;
let subtitle_stream = input
.streams()
.find(|s| s.parameters().medium() == ffmpeg::media::Type::Subtitle)
.ok_or_else(|| MetadataError::NoSubtitleStream {
path: video_path.to_path_buf(),
})?;
let stream_index = subtitle_stream.index();
let time_base = subtitle_stream.time_base();
let codec_params = subtitle_stream.parameters();
let context = ffmpeg::codec::context::Context::from_parameters(codec_params).map_err(|e| {
MetadataError::DecodeFailed(format!("Failed to create codec context: {}", e))
})?;
let mut decoder = context.decoder().subtitle().map_err(|e| {
MetadataError::DecodeFailed(format!("Failed to create subtitle decoder: {}", e))
})?;
let mut vtt = String::from("WEBVTT\n\n");
let mut cue_index = 1;
for (stream, packet) in input.packets() {
if stream.index() != stream_index {
continue;
}
let mut subtitle = ffmpeg::Subtitle::new();
let got_subtitle = decoder
.decode(&packet, &mut subtitle)
.map_err(|e| MetadataError::DecodeFailed(format!("Subtitle decode failed: {}", e)))?;
if !got_subtitle {
continue;
}
let pts = packet.pts().unwrap_or(0);
let duration = packet.duration();
let start_secs =
pts as f64 * f64::from(time_base.numerator()) / f64::from(time_base.denominator());
let end_secs = (pts + duration) as f64 * f64::from(time_base.numerator())
/ f64::from(time_base.denominator());
for rect in subtitle.rects() {
let text = match rect {
ffmpeg::subtitle::Rect::Text(t) => {
t.get().to_string()
}
ffmpeg::subtitle::Rect::Ass(a) => {
let ass_text = a.get();
ass_text
.split(',')
.skip(8)
.collect::<Vec<_>>()
.join(",")
.replace("\\N", "\n")
.replace("\\n", "\n")
}
_ => continue,
};
if !text.trim().is_empty() {
vtt.push_str(&format!(
"{}\n{} --> {}\n{}\n\n",
cue_index,
format_vtt_time(start_secs),
format_vtt_time(end_secs),
text.trim()
));
cue_index += 1;
}
}
}
if cue_index == 1 {
return Err(MetadataError::NoSubtitleStream {
path: video_path.to_path_buf(),
});
}
Ok(vtt)
}
pub fn format_vtt_time(seconds: f64) -> String {
let total_ms = (seconds * 1000.0).round() as u64;
let hours = total_ms / 3_600_000;
let minutes = (total_ms % 3_600_000) / 60_000;
let secs = (total_ms % 60_000) / 1000;
let ms = total_ms % 1000;
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, secs, ms)
}
pub fn extract_and_save(video_path: &Path) -> Result<(), MetadataError> {
println!("Analyzing video: {}", video_path.display());
let metadata = probe_video(video_path)?;
println!(
" Duration: {:.1}s, Chapters: {}, Subtitles: {}",
metadata.duration_secs,
if metadata.has_chapters { "yes" } else { "no" },
if metadata.has_subtitles { "yes" } else { "no" }
);
let cover_path = format!("{}.cover.jpg", video_path.display());
let cover_path = Path::new(&cover_path);
if cover_path.exists() {
println!("- Skipped: {} (already exists)", cover_path.display());
} else {
match extract_cover(video_path) {
Ok(bytes) => {
std::fs::write(cover_path, bytes)?;
println!("+ Created: {}", cover_path.display());
}
Err(e) => println!("x Cover: {}", e),
}
}
let chapters_path = format!("{}.chapters.en.vtt", video_path.display());
let chapters_path = Path::new(&chapters_path);
if chapters_path.exists() {
println!("- Skipped: {} (already exists)", chapters_path.display());
} else if metadata.has_chapters {
match extract_chapters(video_path) {
Ok(vtt) => {
std::fs::write(chapters_path, vtt)?;
println!("+ Created: {}", chapters_path.display());
}
Err(e) => println!("x Chapters: {}", e),
}
} else {
println!("- No chapters found in video");
}
let captions_path = format!("{}.captions.en.vtt", video_path.display());
let captions_path = Path::new(&captions_path);
if captions_path.exists() {
println!("- Skipped: {} (already exists)", captions_path.display());
} else if metadata.has_subtitles {
match extract_captions(video_path) {
Ok(vtt) => {
std::fs::write(captions_path, vtt)?;
println!("+ Created: {}", captions_path.display());
}
Err(e) => println!("x Captions: {}", e),
}
} else {
println!("- No captions found in video");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_metadata_request_cover() {
let result = parse_metadata_request("videos/foo.mp4.cover.jpg");
assert_eq!(result, Some(("videos/foo.mp4", MetadataType::Cover)));
}
#[test]
fn test_parse_metadata_request_chapters() {
let result = parse_metadata_request("videos/foo.mp4.chapters.en.vtt");
assert_eq!(result, Some(("videos/foo.mp4", MetadataType::Chapters)));
}
#[test]
fn test_parse_metadata_request_captions() {
let result = parse_metadata_request("videos/foo.mp4.captions.en.vtt");
assert_eq!(result, Some(("videos/foo.mp4", MetadataType::Captions)));
}
#[test]
fn test_parse_metadata_request_with_spaces() {
let result = parse_metadata_request("videos/Eric Jones/Eric Jones - Metal 1.mp4.cover.jpg");
assert_eq!(
result,
Some((
"videos/Eric Jones/Eric Jones - Metal 1.mp4",
MetadataType::Cover
))
);
}
#[test]
fn test_parse_metadata_request_not_metadata() {
assert_eq!(parse_metadata_request("videos/foo.mp4"), None);
assert_eq!(parse_metadata_request("videos/foo.png"), None);
assert_eq!(parse_metadata_request("videos/foo.mp4.png"), None);
}
#[test]
fn test_parse_metadata_request_not_pdf() {
assert_eq!(parse_metadata_request("docs/report.pdf.cover.jpg"), None);
assert_eq!(parse_metadata_request("docs/Report.PDF.cover.jpg"), None);
assert_eq!(
parse_metadata_request("docs/report.pdf.chapters.en.vtt"),
None
);
}
#[test]
fn test_parse_metadata_request_various_video_extensions() {
assert!(parse_metadata_request("foo.mkv.cover.jpg").is_some());
assert!(parse_metadata_request("foo.webm.cover.jpg").is_some());
assert!(parse_metadata_request("foo.mov.cover.jpg").is_some());
assert!(parse_metadata_request("foo.avi.cover.jpg").is_some());
assert!(parse_metadata_request("foo.m4v.cover.jpg").is_some());
assert!(parse_metadata_request("foo.MKV.cover.jpg").is_some()); }
#[test]
fn test_has_video_extension() {
assert!(has_video_extension("foo.mp4"));
assert!(has_video_extension("foo.MP4")); assert!(has_video_extension("path/to/video.mkv"));
assert!(!has_video_extension("foo.pdf"));
assert!(!has_video_extension("foo.png"));
assert!(!has_video_extension("foo.mp3")); }
#[test]
fn test_format_vtt_time_zero() {
assert_eq!(format_vtt_time(0.0), "00:00:00.000");
}
#[test]
fn test_format_vtt_time_seconds() {
assert_eq!(format_vtt_time(5.5), "00:00:05.500");
}
#[test]
fn test_format_vtt_time_minutes() {
assert_eq!(format_vtt_time(65.123), "00:01:05.123");
}
#[test]
fn test_format_vtt_time_hours() {
assert_eq!(format_vtt_time(3661.999), "01:01:01.999");
assert_eq!(format_vtt_time(3662.0), "01:01:02.000");
}
#[test]
fn test_format_vtt_time_large() {
assert_eq!(format_vtt_time(7384.567), "02:03:04.567");
}
fn mktag(fourcc: &str) -> u32 {
let bytes = fourcc.as_bytes();
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
}
#[test]
fn test_fourcc_roundtrips_through_mktag() {
assert_eq!(mktag("gpmd"), 0x646d_7067);
assert_eq!(fourcc_to_string(0x646d_7067).as_deref(), Some("gpmd"));
assert_eq!(fourcc_to_string(mktag("tx3g")).as_deref(), Some("tx3g"));
assert_eq!(fourcc_to_string(mktag("avc1")).as_deref(), Some("avc1"));
}
#[test]
fn test_fourcc_rejects_unset_and_nonprintable_tags() {
assert_eq!(fourcc_to_string(0), None);
assert_eq!(fourcc_to_string(0x0000_0161), None);
}
fn track(kind: TrackKind, tag: &str) -> TrackDescriptor {
TrackDescriptor {
kind,
codec_tag: Some(tag.to_string()),
}
}
#[test]
fn test_risky_combination_requires_both_track_types() {
let video = track(TrackKind::Other, "avc1");
let gpmd = track(TrackKind::Data, "gpmd");
let tx3g = track(TrackKind::Subtitle, "tx3g");
let neither = assess_playback_compatibility(std::slice::from_ref(&video));
let data_only = assess_playback_compatibility(&[video.clone(), gpmd.clone()]);
let subtitle_only = assess_playback_compatibility(&[video.clone(), tx3g.clone()]);
let both = assess_playback_compatibility(&[video, gpmd, tx3g]);
assert!(!neither.has_known_risk(), "no risky tracks must not flag");
assert!(
!data_only.has_known_risk(),
"a gpmd track alone plays fine in Safari and must not be flagged"
);
assert!(
!subtitle_only.has_known_risk(),
"a tx3g track alone plays fine in Safari and must not be flagged"
);
assert!(
both.has_known_risk(),
"the gpmd + tx3g combination is the measured discriminator"
);
}
#[test]
fn test_non_risky_combination_has_no_reason_or_remedy() {
for tracks in [
vec![],
vec![track(TrackKind::Data, "gpmd")],
vec![track(TrackKind::Subtitle, "tx3g")],
vec![
track(TrackKind::Data, "text"),
track(TrackKind::Subtitle, "tx3g"),
],
] {
let compat = assess_playback_compatibility(&tracks);
assert!(!compat.has_known_risk(), "unexpected flag for {tracks:?}");
assert_eq!(compat.reason(), None);
assert_eq!(compat.remedy(), None);
}
}
#[test]
fn test_risky_tags_are_matched_against_the_right_track_kind() {
let crossed = assess_playback_compatibility(&[
track(TrackKind::Subtitle, "gpmd"),
track(TrackKind::Data, "tx3g"),
]);
assert!(!crossed.has_known_risk());
}
#[test]
fn test_reason_is_hedged_and_names_both_tracks() {
let compat = assess_playback_compatibility(&[
track(TrackKind::Data, "gpmd"),
track(TrackKind::Subtitle, "tx3g"),
]);
let reason = compat.reason().expect("combination must produce a reason");
assert!(reason.contains("'gpmd'"), "names the data track: {reason}");
assert!(
reason.contains("'tx3g'"),
"names the subtitle track: {reason}"
);
assert!(
reason.contains("most likely cause") && reason.contains("do not always fail"),
"reason must stay advisory: {reason}"
);
assert!(!reason.contains("cannot decode"), "too absolute: {reason}");
assert_eq!(compat.remedy().as_deref(), Some(REMUX_REMEDY));
}
#[test]
fn test_duplicate_risky_tags_are_deduplicated() {
let compat = assess_playback_compatibility(&[
track(TrackKind::Data, "gpmd"),
track(TrackKind::Data, "gpmd"),
track(TrackKind::Data, "gpmd"),
track(TrackKind::Subtitle, "tx3g"),
track(TrackKind::Subtitle, "tx3g"),
]);
assert_eq!(compat.risky_data_tags, vec!["gpmd".to_string()]);
assert_eq!(compat.risky_subtitle_tags, vec!["tx3g".to_string()]);
}
#[test]
fn test_describe_tags_singular_and_plural() {
assert_eq!(
describe_tags(&["gpmd".to_string()], "timed-metadata track"),
"a 'gpmd' timed-metadata track"
);
assert_eq!(
describe_tags(
&["gpmd".to_string(), "fdsc".to_string()],
"timed-metadata track"
),
"'gpmd', 'fdsc' timed-metadata tracks"
);
}
#[test]
fn test_remux_remedy_strips_data_tracks_but_keeps_subtitles() {
assert!(REMUX_REMEDY.contains("-map 0"));
assert!(REMUX_REMEDY.contains("-c copy"));
assert!(REMUX_REMEDY.contains("-dn"));
assert!(REMUX_REMEDY.contains("+faststart"));
}
#[test]
fn test_probe_playback_compatibility_rejects_non_media_file() {
let dir = tempfile::tempdir().unwrap();
let bogus = dir.path().join("not-a-video.mp4");
std::fs::write(&bogus, b"definitely not an mp4").unwrap();
assert!(probe_playback_compatibility(&bogus).is_err());
}
}