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:
Renamr Scaffold
2026-08-29 18:07:54 +00:00
parent 03768c398d
commit 1291ae086d
12 changed files with 614 additions and 26 deletions
+7
View File
@@ -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.
+1
View File
@@ -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;
+318
View File
@@ -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");
}
}
+1
View File
@@ -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.