Add real media-file probing (session 9, sixteenth delivery)
Wires real container-header probing into the Rename panel's Match step, the
first item on the porting roadmap: aurora-curator-core::probe reads actual
resolution, frame rate, duration, and video/audio codecs straight from a
file's own header (mp4 crate for MP4/M4V/MOV, matroska crate for MKV/WebM,
no ffmpeg/MediaInfo dependency), batched through a new probe_media_files
Tauri command. Probed values fill {resolution}/{video codec}/{audio codec}
only when a filename release tag hasn't already, and populate five bindings
with no filename-tag equivalent at all: {width}, {height}, {fps},
{duration}, {file size}. 8 new Rust unit tests against real ffmpeg-generated
fixture files (161 -> 169 total), verified end to end with a 15-check
Playwright script against npm run dev, and README updated (status
paragraph, project layout, a new "Real media-file probing" section, and
roadmap item 1 narrowed to what's still missing).
This commit is contained in:
@@ -31,6 +31,13 @@ crc32fast = "1"
|
||||
md-5 = "0.10"
|
||||
sha1 = "0.10"
|
||||
|
||||
# Real media-file container parsing for the media-probing binding source (`probe`
|
||||
# module) - reads actual resolution/duration/codec/frame-rate straight from a file's
|
||||
# header, no ffmpeg/MediaInfo system dependency needed. `mp4` covers ISO-BMFF
|
||||
# containers (.mp4/.m4v/.mov); `matroska` covers EBML/Matroska containers (.mkv/.webm).
|
||||
mp4 = "0.14"
|
||||
matroska = "0.30"
|
||||
|
||||
[dev-dependencies]
|
||||
# Local HTTP mock server so the TMDB client's request/response handling can be unit
|
||||
# tested without live network access or a real API key.
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod cli;
|
||||
pub mod format;
|
||||
pub mod history;
|
||||
pub mod matching;
|
||||
pub mod probe;
|
||||
pub mod rename;
|
||||
pub mod sfv;
|
||||
pub mod similarity;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
//! Real media-file probing: reads actual container metadata (resolution, duration,
|
||||
//! frame rate, codecs) straight from a video file's own header, rather than guessing
|
||||
//! from the filename the way `matching::guess_release_info` does. This is the piece
|
||||
//! the README's porting roadmap calls out as still missing from `MediaBindingBean`
|
||||
//! parity - "everything MediaBindingBean derives from actually probing the media
|
||||
//! file" - and it's meant to complement filename-tag guessing, not replace it: the
|
||||
//! caller (the Rename panel's binding merge) only uses a probed value for a field the
|
||||
//! filename didn't already carry a tag for.
|
||||
//!
|
||||
//! No ffmpeg/MediaInfo system dependency is needed - two pure-Rust container parsers
|
||||
//! do the actual reading: the `mp4` crate for ISO-BMFF containers (.mp4/.m4v/.mov)
|
||||
//! and the `matroska` crate for EBML/Matroska containers (.mkv/.webm). Anything else
|
||||
//! (.avi, .wmv, an unrecognized extension, or a file that fails to parse as its
|
||||
//! container) yields a [`MediaProbe`] with only `file_size_bytes` populated - probing
|
||||
//! is always a best-effort enrichment layered on top of filename matching, never a
|
||||
//! hard requirement, so a corrupt or unsupported file must never error the caller out.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Everything real container probing could determine about one media file. Every
|
||||
/// field but `file_size_bytes` is `None` when the file isn't a supported container,
|
||||
/// couldn't be parsed, or simply doesn't carry that particular piece of information
|
||||
/// (e.g. a video track with no frame-rate box).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MediaProbe {
|
||||
pub file_size_bytes: u64,
|
||||
pub duration_seconds: Option<f64>,
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub frame_rate: Option<f64>,
|
||||
pub video_codec: Option<String>,
|
||||
pub audio_codec: Option<String>,
|
||||
pub container: Option<&'static str>,
|
||||
// Pre-formatted display labels for the three fields above that a naming-scheme
|
||||
// binding actually wants human-readable rather than raw - computed here (not in
|
||||
// the UI layer) so there's exactly one place that knows how to format them, shared
|
||||
// by every caller. `resolution` mirrors `matching::ReleaseInfo::resolution`'s
|
||||
// "1080p"-style bucketing so a probed value and a filename-tag value read
|
||||
// identically; `file_size` is always present since `file_size_bytes` always is.
|
||||
pub resolution: Option<String>,
|
||||
pub duration: Option<String>,
|
||||
pub file_size: String,
|
||||
}
|
||||
|
||||
/// Probes one file by its extension. Never fails - a file that can't be opened, isn't
|
||||
/// a recognized container, or fails to parse just yields a mostly-empty [`MediaProbe`]
|
||||
/// (still carrying a real `file_size_bytes`/`file_size` when the file itself is readable).
|
||||
pub fn probe_media_file(path: &Path) -> MediaProbe {
|
||||
let file_size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
|
||||
let mut probe = MediaProbe {
|
||||
file_size_bytes,
|
||||
file_size: file_size_label(file_size_bytes),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
match ext.as_deref() {
|
||||
Some("mp4") | Some("m4v") | Some("mov") => probe_mp4(path, &mut probe),
|
||||
Some("mkv") | Some("webm") => probe_matroska(path, &mut probe),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(height) = probe.height {
|
||||
probe.resolution = Some(resolution_label(height).to_string());
|
||||
}
|
||||
if let Some(seconds) = probe.duration_seconds {
|
||||
probe.duration = Some(duration_label(seconds));
|
||||
}
|
||||
|
||||
probe
|
||||
}
|
||||
|
||||
fn probe_mp4(path: &Path, probe: &mut MediaProbe) {
|
||||
let Ok(file) = std::fs::File::open(path) else { return };
|
||||
let Ok(size) = file.metadata().map(|m| m.len()) else { return };
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let Ok(reader) = mp4::Mp4Reader::read_header(reader, size) else { return };
|
||||
|
||||
probe.container = Some("MP4");
|
||||
let total = reader.duration();
|
||||
if total > Duration::ZERO {
|
||||
probe.duration_seconds = Some(total.as_secs_f64());
|
||||
}
|
||||
|
||||
for track in reader.tracks().values() {
|
||||
match track.track_type() {
|
||||
Ok(mp4::TrackType::Video) => {
|
||||
let (w, h) = (track.width(), track.height());
|
||||
if w > 0 && h > 0 {
|
||||
probe.width = Some(w as u32);
|
||||
probe.height = Some(h as u32);
|
||||
}
|
||||
let fps = track.frame_rate();
|
||||
if fps > 0.0 {
|
||||
probe.frame_rate = Some(fps);
|
||||
}
|
||||
if let Ok(media_type) = track.media_type() {
|
||||
probe.video_codec = Some(mp4_codec_label(media_type));
|
||||
}
|
||||
}
|
||||
Ok(mp4::TrackType::Audio) => {
|
||||
if probe.audio_codec.is_none() {
|
||||
if let Ok(media_type) = track.media_type() {
|
||||
probe.audio_codec = Some(mp4_codec_label(media_type));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_matroska(path: &Path, probe: &mut MediaProbe) {
|
||||
let Ok(mat) = matroska::open(path) else { return };
|
||||
|
||||
probe.container = Some("Matroska");
|
||||
if let Some(duration) = mat.info.duration {
|
||||
probe.duration_seconds = Some(duration.as_secs_f64());
|
||||
}
|
||||
|
||||
for track in &mat.tracks {
|
||||
match &track.settings {
|
||||
matroska::Settings::Video(video) => {
|
||||
if video.pixel_width > 0 && video.pixel_height > 0 {
|
||||
probe.width = Some(video.pixel_width as u32);
|
||||
probe.height = Some(video.pixel_height as u32);
|
||||
}
|
||||
probe.video_codec = Some(codec_label(&track.codec_id));
|
||||
}
|
||||
matroska::Settings::Audio(_) => {
|
||||
if probe.audio_codec.is_none() {
|
||||
probe.audio_codec = Some(codec_label(&track.codec_id));
|
||||
}
|
||||
}
|
||||
matroska::Settings::None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Friendly label for an mp4 track's [`mp4::MediaType`] - matches the short labels
|
||||
/// `codec_label` below produces from a Matroska codec ID, so a probed codec reads the
|
||||
/// same way regardless of which container it came from.
|
||||
fn mp4_codec_label(media_type: mp4::MediaType) -> String {
|
||||
match media_type {
|
||||
mp4::MediaType::H264 => "H.264".to_string(),
|
||||
mp4::MediaType::H265 => "H.265".to_string(),
|
||||
mp4::MediaType::VP9 => "VP9".to_string(),
|
||||
mp4::MediaType::AAC => "AAC".to_string(),
|
||||
mp4::MediaType::TTXT => "Text".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Matroska codec IDs (see <https://www.matroska.org/technical/codec_specs.html>) are
|
||||
/// machine-readable strings like `"V_MPEG4/ISO/AVC"` rather than a friendly name - map
|
||||
/// the ones scene releases actually use to the same short labels
|
||||
/// `matching::guess_release_info` already produces from filename tags. An unrecognized
|
||||
/// codec ID is passed through as-is rather than dropped, so it's still visible even if
|
||||
/// not prettified.
|
||||
fn codec_label(codec_id: &str) -> String {
|
||||
match codec_id {
|
||||
"V_MPEG4/ISO/AVC" => "H.264".to_string(),
|
||||
"V_MPEGH/ISO/HEVC" => "H.265".to_string(),
|
||||
"V_VP9" => "VP9".to_string(),
|
||||
"V_VP8" => "VP8".to_string(),
|
||||
"V_AV1" => "AV1".to_string(),
|
||||
"V_MPEG4/ISO/ASP" => "XviD".to_string(),
|
||||
"A_AAC" | "A_AAC/MPEG4/LC" => "AAC".to_string(),
|
||||
"A_AC3" => "AC3".to_string(),
|
||||
"A_EAC3" => "E-AC3".to_string(),
|
||||
"A_DTS" => "DTS".to_string(),
|
||||
"A_TRUEHD" => "TrueHD".to_string(),
|
||||
"A_FLAC" => "FLAC".to_string(),
|
||||
"A_OPUS" => "Opus".to_string(),
|
||||
"A_VORBIS" => "Vorbis".to_string(),
|
||||
"A_MPEG/L3" => "MP3".to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Buckets a real probed pixel height into the same standard-resolution labels
|
||||
/// `matching::guess_release_info`'s filename-tag parsing recognizes (`"1080p"`,
|
||||
/// `"720p"`, ...), so a probed resolution and a filename-tag resolution read
|
||||
/// identically in a naming scheme's `{resolution}`/`{vf}` binding.
|
||||
pub fn resolution_label(height: u32) -> &'static str {
|
||||
if height >= 2000 {
|
||||
"2160p"
|
||||
} else if height >= 1000 {
|
||||
"1080p"
|
||||
} else if height >= 700 {
|
||||
"720p"
|
||||
} else if height >= 540 {
|
||||
"576p"
|
||||
} else if height >= 400 {
|
||||
"480p"
|
||||
} else {
|
||||
"SD"
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a duration in seconds as `h:mm:ss` (or `m:ss` under an hour), for a
|
||||
/// `{duration}` binding.
|
||||
pub fn duration_label(seconds: f64) -> String {
|
||||
let total = seconds.round() as u64;
|
||||
let h = total / 3600;
|
||||
let m = (total % 3600) / 60;
|
||||
let s = total % 60;
|
||||
if h > 0 {
|
||||
format!("{h}:{m:02}:{s:02}")
|
||||
} else {
|
||||
format!("{m}:{s:02}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a byte count as a human-readable size (`"1.4 GB"`, `"512 MB"`, ...), for a
|
||||
/// `{file size}` binding.
|
||||
pub fn file_size_label(bytes: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
|
||||
let mut size = bytes as f64;
|
||||
let mut unit = 0;
|
||||
while size >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
size /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{bytes} {}", UNITS[unit])
|
||||
} else {
|
||||
format!("{size:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> std::path::PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probes_a_real_mp4_file() {
|
||||
let probe = probe_media_file(&fixture("sample.mp4"));
|
||||
assert_eq!(probe.container, Some("MP4"));
|
||||
assert_eq!(probe.width, Some(320));
|
||||
assert_eq!(probe.height, Some(240));
|
||||
assert_eq!(probe.video_codec.as_deref(), Some("H.264"));
|
||||
assert_eq!(probe.audio_codec.as_deref(), Some("AAC"));
|
||||
assert!(probe.duration_seconds.unwrap() > 0.5);
|
||||
assert!(probe.file_size_bytes > 0);
|
||||
assert_eq!(probe.resolution.as_deref(), Some("SD")); // 240px tall - below the 480p bucket
|
||||
assert_eq!(probe.duration.as_deref(), Some("0:01"));
|
||||
assert!(!probe.file_size.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probes_a_real_matroska_file() {
|
||||
let probe = probe_media_file(&fixture("sample.mkv"));
|
||||
assert_eq!(probe.container, Some("Matroska"));
|
||||
assert_eq!(probe.width, Some(640));
|
||||
assert_eq!(probe.height, Some(360));
|
||||
assert_eq!(probe.video_codec.as_deref(), Some("H.264"));
|
||||
assert_eq!(probe.audio_codec.as_deref(), Some("AAC"));
|
||||
assert!(probe.duration_seconds.unwrap() > 0.5);
|
||||
assert_eq!(probe.resolution.as_deref(), Some("SD")); // 360px tall - below the 480p bucket
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_media_file_yields_only_file_size() {
|
||||
let probe = probe_media_file(&fixture("not_media.txt"));
|
||||
assert_eq!(probe.container, None);
|
||||
assert_eq!(probe.width, None);
|
||||
assert_eq!(probe.resolution, None);
|
||||
assert_eq!(probe.duration, None);
|
||||
assert!(probe.file_size_bytes > 0);
|
||||
assert!(!probe.file_size.is_empty()); // file_size is always computed from the real byte count
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_container_degrades_gracefully() {
|
||||
let probe = probe_media_file(&fixture("corrupt.mp4"));
|
||||
assert_eq!(probe.container, None);
|
||||
assert_eq!(probe.width, None);
|
||||
assert!(probe.file_size_bytes > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_yields_all_none() {
|
||||
let probe = probe_media_file(Path::new("/does/not/exist.mp4"));
|
||||
assert_eq!(probe.file_size_bytes, 0);
|
||||
assert_eq!(probe.container, None);
|
||||
assert_eq!(probe.file_size, "0 B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_label_buckets_common_heights() {
|
||||
assert_eq!(resolution_label(2160), "2160p");
|
||||
assert_eq!(resolution_label(1080), "1080p");
|
||||
assert_eq!(resolution_label(720), "720p");
|
||||
assert_eq!(resolution_label(480), "480p");
|
||||
assert_eq!(resolution_label(240), "SD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_label_formats_with_and_without_hours() {
|
||||
assert_eq!(duration_label(45.0), "0:45");
|
||||
assert_eq!(duration_label(125.0), "2:05");
|
||||
assert_eq!(duration_label(3725.0), "1:02:05");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_size_label_formats_common_magnitudes() {
|
||||
assert_eq!(file_size_label(512), "512 B");
|
||||
assert_eq!(file_size_label(1536), "1.5 KB");
|
||||
assert_eq!(file_size_label(1_500_000_000), "1.4 GB");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
not actually a valid mp4 container, just bytes with an .mp4 extension
|
||||
@@ -0,0 +1 @@
|
||||
this is not a media file, just plain text used to test that probing degrades gracefully
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user