From 1291ae086dd0cf6f382e8eb18f85d2bd8a0abbc8 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 18:07:54 +0000 Subject: [PATCH] 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). --- Cargo.lock | 75 +++++ README.md | 90 ++++- crates/aurora-curator-core/Cargo.toml | 7 + crates/aurora-curator-core/src/lib.rs | 1 + crates/aurora-curator-core/src/probe.rs | 318 ++++++++++++++++++ .../tests/fixtures/corrupt.mp4 | 1 + .../tests/fixtures/not_media.txt | 1 + .../tests/fixtures/sample.mkv | Bin 0 -> 20328 bytes .../tests/fixtures/sample.mp4 | Bin 0 -> 18316 bytes src-tauri/src/lib.rs | 14 +- src-tauri/src/probe.rs | 41 +++ src/components/RenamePanel.tsx | 92 ++++- 12 files changed, 614 insertions(+), 26 deletions(-) create mode 100644 crates/aurora-curator-core/src/probe.rs create mode 100644 crates/aurora-curator-core/tests/fixtures/corrupt.mp4 create mode 100644 crates/aurora-curator-core/tests/fixtures/not_media.txt create mode 100644 crates/aurora-curator-core/tests/fixtures/sample.mkv create mode 100644 crates/aurora-curator-core/tests/fixtures/sample.mp4 create mode 100644 src-tauri/src/probe.rs diff --git a/Cargo.lock b/Cargo.lock index fa5a3c1..e796565 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,8 +120,10 @@ name = "aurora-curator-core" version = "0.1.0" dependencies = [ "crc32fast", + "matroska", "md-5", "mockito", + "mp4", "regex", "reqwest 0.12.28", "rhai", @@ -180,6 +182,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -1936,6 +1947,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "matroska" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3e606fd165c30a7f5daebca07f9707b2cdca959e0b3dd89538e3e0441b7f52" +dependencies = [ + "bitstream-io", + "phf", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2023,6 +2044,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "mp4" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "muda" version = "0.19.3" @@ -2083,12 +2118,52 @@ dependencies = [ "spin", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-traits" version = "0.2.19" diff --git a/README.md b/README.md index 81d7eb1..020abdd 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,13 @@ TV episodes and movies text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…), video/audio codec, release group — so naming schemes can reference `{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or -FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`) without a media prober; the -rest of FileBot's full media-metadata bindings (real probed video/audio stream info) -are not yet ported. The Align Up/Down buttons reorder the (shared) file list — the two +FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`), and now also probes each +file's own container header for its real resolution, frame rate, duration, and +codecs — no ffmpeg/MediaInfo dependency required — filling in `{resolution}`/ +`{video codec}`/`{audio codec}` whenever the filename itself didn't already carry +that tag, plus `{width}`/`{height}`/`{fps}`/`{duration}`/`{file size}`, which have no +filename-tag equivalent at all; see "Real media-file probing" below. The Align +Up/Down buttons reorder the (shared) file list — the two panes always stay positionally paired — and up to 9 naming schemes can be saved as presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as Settings/History). The app can also be driven entirely from the command line — @@ -57,7 +61,10 @@ user-editable naming-scheme-style expression per loaded file (the same `{ }` syn matching "Match"/Filter use), with a live "N of M lines rendered" count, a Copy List clipboard export, and an Export to File… button that writes the whole list to a `.txt` file via a native Save dialog — this closes out the full original panel set (Rename/ -Episodes/Subtitles/SFV/Filter/List); see "List" below. See "Porting roadmap" below. +Episodes/Subtitles/SFV/Filter/List); see "List" below. Real media-file probing (real +resolution, frame rate, duration, and codecs read straight from a video file's own +container header, not guessed from its filename) is wired into the Rename panel's +"Match" step — see "Real media-file probing" below. See "Porting roadmap" below. The naming scheme is fully user-editable from the app (the "Naming Scheme" button next to the New Names list) — the default is `{show name} - {season number}x{episode @@ -188,12 +195,20 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported), into (auto-created) subfolders when the naming scheme includes `/`), a rename history/undo log (`history/`, a partial `HistoryDialog` port, undo-aware of which - action produced each batch), and pure `-rename` command-line + action produced each batch), pure `-rename` command-line argument parsing/file expansion (`cli/` — no Tauri or network - dependency at all, see "Command-line usage" below). Not yet - ported: full media metadata bindings, other web datasource - clients (TheTVDB/AniDB/etc). No Tauri dependency, so - it builds and unit-tests standalone. + dependency at all, see "Command-line usage" below), and real + media-file container probing (`probe/` — real resolution, frame + rate, duration, and codecs read straight from a video file's own + header via two pure-Rust container parsers, the `mp4` crate for + ISO-BMFF containers and the `matroska` crate for EBML/Matroska + containers, no ffmpeg/MediaInfo system dependency; see "Real + media-file probing" below). Not yet ported: other web datasource + clients (TheTVDB/AniDB/etc), and everything MediaBindingBean + derives that neither a filename tag nor `probe/`'s two supported + container families can provide (other containers like .avi/.wmv, + audio-only metadata like bitrate/sample rate). No Tauri + dependency, so it builds and unit-tests standalone. src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend (naming-scheme rendering, filename matching for both TV and movies, metadata fetch — `metadata.rs` for TV episodes (against @@ -229,7 +244,11 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands whatever already-rendered, newline-joined text the frontend hands it to a `.txt` file — the only new Tauri surface List needed, since matching each file and rendering its line both reuse existing - commands, see below) — `cli.rs` (the + commands, see below), `probe.rs` (a single `probe_media_files` + command backing real media probing in the Rename panel's Match + step: batch-probes every loaded file's real container header off + the main thread, wrapping `aurora-curator-core::probe` — see "Real + media-file probing" below) — `cli.rs` (the `-rename` command-line mode's orchestration: builds and executes a rename plan against `aurora-curator-core::cli`'s parsed arguments, with no Tauri `App`/window ever created for it — `main.rs` branches @@ -238,7 +257,9 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands src/ React + TypeScript UI (Vite). Faithful recreation of the original two-pane rename screen (Aurora theme) wired to real file loading, drag-and-drop, live naming-scheme preview, local TV/movie - matching, metadata fetch for both (behind a Fetch Data Settings + matching, real media-file probing (`probe_media_files`, batched + alongside the local matching calls in `RenamePanel.tsx`'s + `runMatching`), metadata fetch for both (behind a Fetch Data Settings modal holding independent TV-source (TMDB/TVmaze/OMDb) and movie-source (TMDB/OMDb) pickers plus the TMDB token and OMDb key inputs), a @@ -519,6 +540,37 @@ and the results header tracks "N of M lines rendered" live as the expression cha to File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text to disk. +## Real media-file probing + +Unlike every panel above, this isn't a new sidebar screen — it's a naming-scheme +binding source wired into the Rename panel's existing "Match" step, closing (part of) +the gap the porting roadmap has called out since the beginning: FileBot's +`MediaBindingBean` doesn't just parse tags out of a release name, it also reads a +video file's own container header for its real resolution, frame rate, duration, and +codecs — a naming scheme can reference those even when the filename itself doesn't +carry a tag for them, or came from a mislabeled/renamed-already release. Aurora +Curator ships no ffmpeg/MediaInfo dependency for this — 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`). Any +other extension, or a file that fails to parse as its container, just yields no probed +values — probing is always a best-effort enrichment layered on top of filename +matching, never a hard requirement, so an unsupported or corrupt file can't break +Match. + +Clicking **Match** now also probes every loaded file's real container header +(`probe_media_files`, one batched Tauri round trip off the main thread) alongside the +existing filename-based guessing. The result feeds the same bindings the release-tag +parser already populates — `{resolution}`/`{vf}`, `{video codec}`/`{vc}`, +`{audio codec}`/`{ac}` — but only when the filename itself didn't already carry that +tag; an explicit release-group tag always wins over a probed value, since a release +group's own tag is usually deliberate. Five bindings have no filename-tag equivalent +at all, so they're only ever available this way: `{width}`, `{height}`, `{fps}`, +`{duration}` (`h:mm:ss`, or `m:ss` under an hour), and `{file size}` (human-readable, +e.g. `1.4 GB`) — all formatted once on the Rust side so the UI never reimplements that +logic. The Match status line reports how many loaded files were actually probed +(`"N probed from file"`), and the naming-scheme editor's field-reference list has a +new "Real file probing" group listing all five. + ## Porting roadmap (not yet done) Roughly in priority order — each references the original FileBot source file it's @@ -526,13 +578,19 @@ ported from, for anyone picking this up: 1. **Full naming-scheme bindings** — `net.filebot.format.MediaBindingBean` (1354 lines): all the `{n}`, `{s00e00}`, `{airdate}`, `{vf}` (video format), etc. binding - properties. A filename-tag-based subset is now ported and wired to "Match" + properties. A filename-tag-based subset is ported and wired to "Match" (`guess_release_info` in `crates/aurora-curator-core/src/matching`): `{vf}`/`{resolution}`, `{source}`, `{vc}`/`{video codec}`, `{ac}`/`{audio codec}`, `{group}`/`{release - group}`. What's still missing is everything MediaBindingBean derives from actually - *probing* the media file (real video/audio stream resolution, duration, bitrate, - frame rate, file size/hash, etc.) — Aurora Curator ships no ffprobe/MediaInfo dependency, so - those bindings only work when a release name happens to carry the info as text. + group}`. Real container probing (`crates/aurora-curator-core/src/probe`, see "Real + media-file probing" below) now covers the other half MediaBindingBean derives from + actually reading the file — real resolution/video codec/audio codec fill in + whenever the filename didn't already carry that tag, plus `{width}`/`{height}`/ + `{fps}`/`{duration}`/`{file size}`, which have no filename-tag equivalent at all — + for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Still + missing: probing for other containers (.avi, .wmv, ...), audio-only metadata + (bitrate, sample rate, channel layout), file hashing (`{crc32}` reuses the SFV + panel's own hasher today but isn't wired into naming-scheme bindings yet), and + wiring probed bindings into the List/Filter panels (currently Rename-panel-only). 2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods` (636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`. Only a small subset (`any`, `all_of`, `concat`) is ported so far diff --git a/crates/aurora-curator-core/Cargo.toml b/crates/aurora-curator-core/Cargo.toml index 18343c3..3b44049 100644 --- a/crates/aurora-curator-core/Cargo.toml +++ b/crates/aurora-curator-core/Cargo.toml @@ -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. diff --git a/crates/aurora-curator-core/src/lib.rs b/crates/aurora-curator-core/src/lib.rs index bd2f595..a20c38f 100644 --- a/crates/aurora-curator-core/src/lib.rs +++ b/crates/aurora-curator-core/src/lib.rs @@ -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; diff --git a/crates/aurora-curator-core/src/probe.rs b/crates/aurora-curator-core/src/probe.rs new file mode 100644 index 0000000..09c6ee3 --- /dev/null +++ b/crates/aurora-curator-core/src/probe.rs @@ -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, + pub width: Option, + pub height: Option, + pub frame_rate: Option, + pub video_codec: Option, + pub audio_codec: Option, + 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, + pub duration: Option, + 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 ) 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"); + } +} diff --git a/crates/aurora-curator-core/tests/fixtures/corrupt.mp4 b/crates/aurora-curator-core/tests/fixtures/corrupt.mp4 new file mode 100644 index 0000000..398e619 --- /dev/null +++ b/crates/aurora-curator-core/tests/fixtures/corrupt.mp4 @@ -0,0 +1 @@ +not actually a valid mp4 container, just bytes with an .mp4 extension diff --git a/crates/aurora-curator-core/tests/fixtures/not_media.txt b/crates/aurora-curator-core/tests/fixtures/not_media.txt new file mode 100644 index 0000000..244d116 --- /dev/null +++ b/crates/aurora-curator-core/tests/fixtures/not_media.txt @@ -0,0 +1 @@ +this is not a media file, just plain text used to test that probing degrades gracefully diff --git a/crates/aurora-curator-core/tests/fixtures/sample.mkv b/crates/aurora-curator-core/tests/fixtures/sample.mkv new file mode 100644 index 0000000000000000000000000000000000000000..488c000fa7c22cbe2e63df52337a63d1e29e40ef GIT binary patch literal 20328 zcmb@tbzB@>^Dj7qL$DAmNN{&|m*50<2<|#)f)4KP1RLBTxCep2FmHL^A6Th-6q0Q1TY`xeP*(yb3~*1V=cUy1O}p>`f)Z zVNIbyaD*Cx)>=U2JZb^CDG`Bb%Iq1fB#Djpz#mU|9>wp{EYR4OaKcU8RzSRu_VQn17np; zJ*{}zSvh!FIoR2CB>ra|RFsfL&A_%tTFJ_1**Cnoh;h6`*518uT`;I!&OwAzAzTBkvP|H=an05C7C1zG^!K{o+J*0ArY zMe%&LdzaS$06YNpaCiVG7CuWF5P)QJygf$Q`*e2)_%Bn6^FeShrqs&P-=DSQCjU>S zLM2QjB&4(}LV#u}SHVGWn~8Zrz@`ZC-H5=KS(;e{k-sovUKomlqP+`vQJnZ||39d5a{WseKql4fL9V3K6GBiwD!Cs7fN1sx0RS*bz||ljLZKBaGKXDzi*hY# z%nRg;RHtVz+>*19n>)K%l5@a{Zd@Gf{N!95mK*|B0ySoUov3YrUv3lBCSUNkHIyt#KzK_9OPjJEAf%LfP7%L!mcK679uZV6=a89$Q^CHEiFu5D00B=FmW?= zvbGfA;3YS=adUPwHG$pALGJEm>EK`sf|dBa`7O-dVFhznM-g@y3#JyIoSiI1I60U( zILNI`LGC6lAbVSvml6LGaCI?pwz2|Qx{I)IlDpfu!R`RTn0Ih?wl}qbRZRZd#z78p zur-JA^4|t_awoTco?&ha+k{^Duyt~`baOC;4T80sIe564`k0tIJGz*snWNztXY3|`J!p;7| zrkm-DNZl+!Hn7{>%uW8+bYJ@2%tg%ItjHbBU~+w#7OWz|$;HY}?)owr5q4HySkdK$ z@IT+C-XgpLum+I3rHcqRxvdLKNic(e=@3TC)D@=x7j3>+EDn-oL^5WWok67J>)EaSgpGEaR{PtY09u)l#clJ2;7Rbq=Y?etOXwDwF_)aBdhVLgrlbxm7?Y?@zk51FQGYwWQn0Sm1}*SV(_~aC6_Yk3-kUB} zd+Uz!sdM!G`Kxd_qq^%$*FZZ`iI1n`l&zdXheLo4`CzW|Qf7;xU}E{I`H)&mGCtx^ zHTv3B8lj2c-E5^_a9_{mi2F?j!Vl_r_143NeYIH~)$Wic2D@%Sr{3jxKWDZD+(dh- zDlYHZemRR+E#g^*S6G(Hr>bs@(hs(YK!=_W%bc3ZKP1 zvRYAYjDY}}e?|H}?S5sHr+mUA(dhy=FJ~)`00YnBYNvZ|V5njtI_vb0VZ+vU%;+s} zls%=oU4iAtjUqyWmbRk#Wx%Vl!ZzvNE67umGa;7z{KzOh)y3CupcWE}1?I$6f4tqp z@pM5m^NZtdkJL2>N!54H2GaI4p^|CB?{`TU`K(wk#qP>?cG*L%*DBG8Ey|9g%Lz3b zq~;cyx5KgbB~-;m-&74%2%r;iS$t_jg_XC{yliDkk(323rVy__fAAKZ4=sMfQI<@{MoDOX>6v9@$NIW$q2UZ)>e}H6-4eGuO8l{hh`> zuX@CDR#l8>dA(LSY`~g6IGHhN!^VhQ1mv5amAIAm;yRQBP}X9 z*g*V3|HTgJZb6(>~0-|a746GPF1dp zuUl?Q%yWq1Zet)s=S6mPuy2+l#gLZsO1EykiU`|GQ~cOYx2nUurh`Wx^T`9!7`hcjuB9K-s8v%V{j_zdY=JPgSL@9c7161O>nb93cDhz0ucz#QlLLUe=z$V0z!6B zgnXPjWErBZKF>uyMd(je>U*>_70`?z0_~6ra%zd{*qg#uKz!3cBI9heh%;@LU-LxLVit zuuW7awT#Kx29efR1XQafKL6R4+3)$3=2!y-GfPlOkrGjXd0P84L)HsaPWU;J3<->M zw#>Nk3|MhS9Cmf1^bV?`9)y40cs@M&zLBWvyeVFx>zj9_(h%O#LgzwMQ=B!MTYSEx z??Lj6HVhKz7VQgO&AA@;*sw)aP7tjVOMgu0F_{eEJym(D*=qWV#j`-Nd0X&nZgARS z*AoX5w*1v#oinE=`n?ji%C;qZfZr!|h6}Sgx;cyKNcwx{`)e_yoG3U^-bvQb#`HI# zn8e%35vIzBU?P5!u?ScG#w*px>T1XREuvp6mK|bhmh>IJI7zj{zjF$_p=FPbu8hA? z8Y(;)=Hauyv>c*SK4_@poABRWLSPaoG6@*AM7t+r-W%~8#Q245-pqVM7cspjme+UQ zKlgS($zfjVyD*Ig$d>7e2d(D46UP?rD&~}_%0oYcyMNao-)qjBo$%z=eVw44+mHEp z(ppI$4RzFAXyY|=ROCi~w4dbi|CaKv!SVY2zU(}Xdt0W=i|2apgmC={4#DtlNFz1J zpK}a>y!c4iofl0DM7nu>&RtAw)EzUVcU+f5yS9exl;(#v;jvJAW1e8chzAAiGu~c2G%cDBUfFI`hRPn=*Y3A)q4~e}JOM#se z*PIxQlssmqrGkLk1U#2D5V7#}N&^3zPOXC9d!AZL3<#t#!Jst4R4$#a6-n zb^ZkN{LDuN^ioC7A|g9MPkDo$lfIDd$^ z3xPFr26Z@lm(ZntKFh6CvbE)>83)Rt;zgu5xjTOI6h7iDPe;{(LCygsk8NN<AGSoj!9ARc9}sF+vc@^bqn*Gyc-` z+XW$2L{*Js&TLW++%+uObL|LvZDPs=gzvvs-wz+Fvu4a+Y4G+;{KOpUdOWbH?@ChW zc&98>z=(62=7kYTo+`&UMm4L?^J+5za>^^*6Cn`(HRD(EDc#2q_IZ1=$%6zp^@zN9 zXxOYH@xJk2-4ovy0PDhhRJ z*8I;;V+8M*%3sZ1kQA@p#?J$`thot&>q|wJQ0Bpfb=}^ON}mtL%{o3oLM> z1yp)n8u&5!u87N`dHjT;nP`8GLR@)i29WCHXZ@j${v#}P9@=hINtcznD0=%u=aJ+7 z^gbPjrjr#^v%ZxZZk;kz`JsHY)v8{K3ddb7CX2OC3&Xa4;5Us@WF=k3{#)+3`gVNN zW9X8ap9zn8-d%?furJVeR@W%C#6MA#Z|_avUwoV!P}3+@{B@V`Ge9{L;N3YJZ{*ZW zB`vf3x>wu%A$sbPaU}0!Wi|s?g8^?molh;Z-%W^7uNZ-f-U@5&onqjnIvW#a8q&MP z;u)^nu|BC%v@<8KjEqm~C3|-?&yHpWWjqoiWG8C$eSvP^B1x6vn8kI*6P)gS6Zt&E z+JSoxS{1Bh?f1Ns$+<#qWDo|Z;MV*B^A~=GVf$s&F65qY#?bmVGasJJo}tXPoU&WI zH?KdkA<1M+%fH5&P@Bqk=}Fd&bkX`nHFOz%Stsp1SUb8d1sDq9SX&8OV98)Xk3^#X zf|A>6#6(K_onEWKaihNhZ_%Qz@AjOp;|MW+r$dn^S^-qYSfWE!)wXuS95mUpqbnMo zX@8X2lTT2t^yZ^Q#NnQNDo?C$QUz85Ud-b2S$jQNcOGuwL6Zlg(qf@S8Vza)pf;wP zB|)JniuNa$jbG>~6Q(`0U_xn%j42k$o*s70t`981RfeOZr14d&NPD9nlK^T`5lqQ8VT(T;TB)!D~relZVgdVfw#mp(F& z{wV`xjTA7yh?o%JWi18qBvOyju#`Hwu&F#FalYrh9vmuI-{*ECU`KC%b+Kzr5xE{V zsmgM2_9G#~$CUC(`zD#c<}Yn2(-mdh#&u@#h7)b6D~~)T8NI3K+m2cD$03@LN)Z-g_sE4oDlLtTo+`Uz z&ykfLa%OTavN)n%gZY^af)kfG**Yd&(jsc+otvCDk8*}UoZl@Gi2k3B17vL|`0Nha zKl#3I+_O3wk59qg=y#(!ObSkw?asq_FG|?&-gWV??YzL zX70GyXWkKcWAuLPtr`U3C`K|lJlPBF)|u?z{J9zMrF7%*da{3v>zUULFb1HGh+X*c z0UWg{s#<<;_@vDis$ihV9&wjscYI^+8i*gMj6ggF9h`VWeM2~rh;A!?08gn?{?1fc zjPjD}k^M0$#J0Y9qS@y@Gc@UTxH?hM8`TFnPg%0K7weh$(J7qmIuq9@_a)t@6CwDz zewWj0vUEkX3ZwI_Y}gUK??ciyX@{@uxPLeN?1p{EaY3!Z%fS3>Al^YC676dT*Yg-&L}C`I`To zM75GtsY1&i2DZ5cf};Q z!at^@ny+aDj4oXh3e){$zQrfR8SZ*T+1%Qws=UYS!o47^RXvnlQhPk8-OZLAIWPQB@&%1y%WM20SK9>CLd`o=PkgrvmX(h3tXwN2FsR3-GCU9ML;oTftG_xLmV*We6`HZ-%ENZyAamtqms`_SUNO(Y5aJTBY(^0Y7h2PQr|9c)YbRICLCw(xPjQ zu-$e9jRjlp4y1Km;aVCR>uCfQ#>+Ngqdr<)5!D1uvzqSd7*o(bBgLAy@JilfjV|L# zKlb<}i5{dfJ`Vavri)rC0oJm9!I4USqccAIQ$cjUxx0I`X)hIap|8OjNL)yBg>~te zJ&L{&ls0js`CVPSs#$IO*t`6ZHx>z_LZ4xJg6=b2RRf(Y@o=DNeLxI0N`(xcfHMcb zHkuqw$n@&cC>;vzM_i3fArJdgg~zno*VUX_4-!3-8uux_g*_kVeXrjj$2Yu-WAnaF z!XX-KbcPP;fAOce4e`Vs`9KyR&0lxW|E5}4A9!}&c2Jg=){5%cxiaXWdGWrHW#U$% z@=9mUfi&au&*Sx!t-rE?@D}g~b@i{h6i`4XpO-qjrnfc8D_Y z6tr%P>0!AouHE)*UN$s;MNl9g=*{j->#KgDfwJs}Y%>w7(#VIN%$IcJF>coM!OMMB zRl@E$O%Qu;>JcqoXhm1n6C+M)bZvimW-TWJ;duS4*6^;#VL(R>wpIe81l8V{9z($V zXC+rmDZCYEsZH$~sh#H9 zy0&Z#jo--kx8*crsh0DiJ6m1U&Z}cH@TlyHP6Z^2rNn%2aV?n&8_?TRJQw7-XKml7 z$`G4Kuo&7T#q~MZ@e~i?V(**bb|EkR$)+5@nX}^J1GLo6m<;X1cb%*fplTOdbh3YJv6iPz1+ z>86$Cl`C*ORgCi~>PUa9YIt)^p@nw>$A}cWNQb_`tFwB_J&ope=L%Md#iMEg0$nHQ!NUt{|J%b2E&5tYjA-75u*pdu~!-~Sg>e( z$|&c`Et>Xoq8UvUYN4AUm-rGW4fvZr<9aR^NF6~*MJtLV0whO%7f*)_d`m$Xg(7c9 zIZT~PNu4es-s13P1UD=>5PQ3FB%^~1m0q#HdR4XF=u4P%Z*TY)DGl@pWiRpwM6WkM zB!pMEWHmcqVq4rjvar_u=g-%_+&dCie^s#?Yi%wcXgFmF*)(wKN;zIs5prB^TB$S| zt#3Uga9Z{_X)zv!U7Y4UPrRJwU6)RUh~27IDtRi#?Y`=_&ei-|sG8Qc%`^Fb`-`Mc z`hPv$6w?v;8TvB{qv-#8vO+e303TrM7@#YWE9o5yh4Yv@KL;?P+P^JnmNEvQH}lzT zj6fLX_f2l4#m}P5h*%Tjm}(AU>8jos9vyq49D34pz9Q%nPB8DeckwSXcqc6S*?_dg z%|BEr2pcS%LNNjne*gmr0)c2*OG_eKKX|1EG9p9uZFDrCwf8rPXO&$hSkFDTPY!JZ z$6~e6MQG1a<6MpEHS`ABGgn=0wDUIloaN!NuAQ2CYwwIEGT>S&{H$wRB>Gw+p)Gh> z^UU!a<=(w( zWgtX60SsUb1j600OuD`M7FrlkJIA-bFLeL#^tbydwPJ+qrnQYyKkxpoF35=@%uY!k zKZ8k8nR8S6G^U<@496^{Xk&-nh~kVyn`wayZI4-z!-~;v4K>wn0 zSzxZIyJQc%h=Vcp{htQp3SdtdXcBW@8fq{%tBbgo*~s2#{w_i`O>K9cN4U*T`-+|( zTd@4Cp%>oWSTE$c4u3j2SK19C-U0^51Oj$=-{LDs6UYF6mANyLqJ_&*{OXiH)4G_C zhfd!?@97L{#ud=ihQ8X%i6lsaR%5^BqdwT@rw4w9D*gNu|Ly|v z%ifN=b;KgKfZ$b*qW-vfvW%pZFusELr`4jh$Q9M)&ev)UpY@@-9n8rixIsu1$-HG# z)f2t>?s*bN!+yFC6PH_gouQQtM9_G+Sq#x!lpc*6>`*q=AsoZ`P11;5mnkbbeu(%t zFu*7faKKBU&F>T%3{kV05#+*;?Dj5yyAGX(IzL}t|9q3n;1NHl-Jo(;y$W(e(3c^P z#7zXNU_7v>8dz6^VOvAj^pgX1k@xOYq)Pp}+1}kASJW45s|;0#A$_a|4;Exg`$!)p zr$l#VpC_38T4*N!W>-l~jp}P3H1YYQ%J)aKeXEEMs)o#h1rU4mHT1))29fMS&LU90SmI+8<43WfcJ|w6@aT43!^lia>0iK&80W^p*J;L%X#lipry|N*E88O z)>S)uU)x>YxE-E*~|DBaeFh(!k zZ3|KLe1wP(f&tMl#!Pr)o(k=lmo0szfc#%Agb>qP_{hlfU%CR9^uLhtC@NpPZgzYao{u&eXvB? z-wLYcgoB(NBk>JMS-96~!*7Bp{!RtXYh4us$_&+Aj(v+qM&@&cOAP6<9&pQAKPWbF zXcy>m;+<2`AYCtBr z-F>{*>#DK^#{I>=3fsBxlCjh-L327YnECI zoN(hCGNPd0SuFnkRu1~Wv5dr4J0@2yhB!xAql%dtNlE`b^x+#x%Bv1p2br(DC1!`V zJ#Qy13@Q7Vv=@iuO@#R-TM<$YA|@6K7N^7?I93~U{ke%$**{X7O>=&tB-@R#Q|QNV zK*=4Nr$D?LoHK&b3V4&C$-kzIuVAoazfMbMYyTSTF!i-9UKpk1!``TFq))vGi>DB0 zwcTJ3Ke0p3>MgErZ;OD7c>DKz(tYj9Au;l24y~4354g~T_k*zxaAqe+07?LbcfG0Y zl{C48oVAv>`lL{5yM_)jJt1yrVj;FKrL9F=bADUkkVE2{xGZxw=FwV3+&P&y_KHxn zY>!xwyN|9GTbT?4iNwbY!BKvb))*GT`5T+Ld=d;Od|4(G+VoOXk_Er$8)Zq+ zq9qF_yGL&-bON9{Ptb=$#zV>rZD?8b?r8a}0O2ep3Yff!r~GS$UA47Y3V<{2<`c{O zIo9gNX}y>Jid{~X-tYtkBNzBipEhNtC;}Wr_%pl`CDLAFMC$A4GoA(wZ^4&}qbt#S zhN^4w;_m#EhJ+g$SyLx)9V>Pj9mcR(tC%|-d>bA9Mh6Jb^;U;uzF>m1HpkW2#2-79 zIZOv7MX1#s_1x0bu|L?}1fkt50u^LfY)_*Q%`o5unGXV-zigzc61ibAubJ7P|NIK3 zU2co5lRH>h`{st$SmM?^^hrIwt;1j5p`^*Ks#iB~UODQ%H1wJNlvtXKg2 z8_DFzqAWusLfi{!!!BAqrJx`>C)GkVW3;IdS5Q>*~T#?m&Huurhi zw_!Q(v)(q&FXHp9;-aPaGCi$Agh^;CM0^|!`1P+L$m&BRZ6+g3k|TMJpwBIT1Ma3O z^okiPwB(*j4D5O#ny(Qga9B8JiHzb7v$QzBVO6XKf5qbl?2*z$Oi${76bZcU!^2+d z%gugm_!`|`bVB7*?BT(%gRDSYAEp2M^V*D!6xsnTkP7D!LxP6nuxVX1ReP4nOIguT zL4B)R-Zr8pgwbwE*OP}Ywfr-_0%F1$s;i-~epXhCMP|q4DQ~5KYHLaA3B74`h$)$u z+t^Sr-dxY`sgz1FK*LAtEE?({>lbh|{9*+#a8wKfM=v&j7|DLZH&mxKThEMYT-d}b zfJxK&gN`OI-}rh$Oy9WD;vwJXk%R=`-sp^bRoU?Va&PmGQOq>f&k+o*XFSnF&^!WU|Jdl%S{g2zu5R{Za3Vd^aFmtK*P$~~I!0>D<5(JcLxU4Gtn|ccf2hVcY=*Nm zr&xnFT}g6hqL^Zx9DE!ot}sJd^GxDW$}^hgra$7SkV2hk7u8I%`3<8#84bO?G*1yF zJGEmgf;k^Q!384T2L?a`0c)_WKvn@F3HeplNXhc*riLLs3;H*&2O6ngm0DRbZb^;Q zal@5;)k5X*4O<#FQ66>@$raNciHoQV4x2w@onv_TPW}3UI{JOJ6yx(Dk%{13yHTlK zjQ$*rA6H`_vQJ`=a3bD2Wh&u;W@EdPk2N9~U6(scadIB2#BQGF#k=zaBPt8~Bk1EY zzYSN(X%<@D^k;`PY3xHAluy0`aFNYx!%5k{3vkfG>4wv4w@KB|CoL2c5-d~tdqLWR zfP^n<=LF`K{uf0V;`j(0*RZFBW~KIUMonp42b^oZvZN$x6ahB>{rw%?tdb8GxpD&{ zz6}QA2LkqZ-@^97*W(3R2e1TgE#5qIA4>MjJLRn9rj19Iy-hr;Uh65$ZY_}|U-awb z*I{7|&!-)@**kTYvaG;>p8lSJ^{wr$fXKVVj5mnMZ}&%7E(-*6B4W7wq|webGhgM~ zV70DlD*dn*2lPHvbYl;E29 z^!IscJSagomL-%^5!1xDS+Nc)~!tURKI@j{tSp` zbhPfq_z^e9=&ZXZb(T{2;NHB}!tp0FmAmGJ}iZ}mp~MarRrr|v5{G9&50@=8xTpkAm9c}-*Dm*SyeD}4tOO+=yA6{2Ohh^ne@p(?x}L9Fw4(qzcWGZ2L`b#;qsu$zV{MIh390(Ey)kZO`VF zhFQUC+g&02zC?t|q+RM-5bxArWTMo7i)p6Qz=XEQ=Fl}y%pIy*%J$*!(Jazgp9F+$ z`%x3+zP`hBwvqa^(_0gY$);&l&-)}ki$KIVK=xJR*Q}p4}T%7cbyY;=NHNQ%T$(UZvIT!UVjRF_TRAJOl5F(7`WqR)XyV4&{I}V+i_s<FOk=RBf21uvj}^kyXsxdyoBj`bWL4)!6UH?`a9+oBrB)41{_Oz*h&!V zdNQ>SHb;f4&55C7B4mS{gPS9I-~WDgkzU!tg-dOUW`E6BI@{pisqC#vh~ALLBghqW zKJmn{T(uBFs=p%k>BebNdt=|$UyC9%0U|yR2L8wWwNe<8q@3)w9n^M#{#`$W*5Z|~ zl~*X&;?eQRYd(wwcl`MM`-FN*nU+(Ts5OVK*N`{RVuV;6P+>uL93>GTW@yN5H`G#z zh2F`MIo15yg{i_W7<$3sNop3iK&HUQ*|CNjMY(ENuQdADJ$mHl%LE=G<&CUC7by3s zjWJ;wOY1CYoQ`@x9Iz$#on=K)Z+)fq3dKv_$1QrJpKHRozR!W4rVycRd>3JcT$8~f zT62{=Q6Z~2N2{3CnZXc6dM-&>E<;kfjPPo*d+Z#t90W{&tr38RMDC~z-am-wJ5s2) z+R3if>6TQ0*k_kYLhigevBarIAz%urG=9fM8+Fc#e7aAUHw1k_&e8D?F(7*nnr+&I* z@g(eRM@oG1{++0>EA+Y>3Ps7D<2Gh2ds1Fiuk=a?$d&c<^z?PHwgXqwcu*r4#+v7i z%?E;-!gja>d_`9&;-W)kHBkOn39M3Anf;@km7_qfRkuR0-;QHjH zXn;8~Pfkd}SNO8-%l!$?l}wNs@0<#L`D^d$e>xk*`LUns85LauXTno!M-xVFJIjjZ z3evG}bK$LKAclU&74PK_53#BG382`CrK`!cP-Xa28=QnHh3+zuq4D#; zqeIx|WFc~Y;l$MhDYvbxo9)H;x8JF0pS@>1P>f`R-}suO-Bn=0iNGJGfugA5YJ_Ql zg5r?)ZNM<4xSx@q#?+B1b@vs`t8`idR%J%U$#PO0d!K#y8$i@QiSCNnAr# z{$*=55b;(p@V~B(VllyFw*|p(}1Kh2iKPJfifxy<`Dsk@R+5`>V4ki z18e&m_@T>OJ3mM~aTswbp;QL_IDpL=Bq7lDufu7NZL0b5^`%M8pt{x7PrJKfU-cLqNV!%*Hd>36zd&o}?An{7UyN6(W8J2DZJJIQE>k5RLpx2*iv9CG<>dz+*cc zdiWIZv^2JkH+WjhD4+fHCS~mODExA8H~q0E+d*s=FOQ=;un|t0X8J>+H(-LGiyZn# z;695#IAZn48P9t|=w}_yYff3b-5*R#RS9@G7SbpmPYS%#0*gM1tAAaVDqBJ@c!cY{ zn)E;fpgI)L8{lW~`-dDjMjp6r6d7Nx4&n-}`)U0lKq$N8j~<6IZfzHp?Jy#x6t43# zsT%mQe)-|jtrA)0;-UI3l!uR=pJjrnpwxYZ!c19)C_J z{g?=7w9Pz57?4O1YK+}=hY9Lqr5d~zU7=O-rb4an%RV9z%LUEeOcUVC+455AI%Afo zc}0cY^b{+JXzZ3L~I}eO- zGrJB*GJWSSMBb3lt8g>>+NtCwn37=axVM>|2$v%=xol_U=~YZ`{&Riy2&Fa}efp4O zIi;}3nVsVYX9!i#lY=n#EoF^6Xr;v#y`nq|e>84o?{Hw+ z3Cz(z%ELG$Q|5Pj?fSQtOD&8A7=%VC+#G8&k$cO!mKb;os>Z1`(&IJSl+{zVR^a@W zSoibs3v&l7LROmukCMDJKUJO2$;WETUaWQhAU@Oq0tRWzQGXI9%``=N&|{>jPTiSB zL~<(Jc88>odjrZ+Z9_@W5U!cfFaC`DU*WGWAmT${;N-tyQC_ojNi*0hi&hyWTJmw# z$YWgWx#c0?79xA9ShY?Vpd~{WN285ymxv&Ntu90D(B8`IR^?C!&-U#4!lAnD{%@X>*5RZEy;@`o*{eQi7UWHm%)F$Ikd|E{<7PRtN?7EF9#!yRZ zv!i8KoAx?;a-5WIVt)YnXk}GL->Ej^conSbai@E{x(Y4jHB_$@y z+!aU(!=;}XW^Y-0pzj|7?p7+DTIF>afa40qJU<2B3Vn>AM9H*sRHNb2O0H>gz=*RH zSBmKYSS~Y4gv+t-D@IOZ>Jejn`T9N-WXhde@pmw>cp-U`F<$OE8b@K_OOSJ58v<87 zGKCKqG2BF5H~Xl2Fl{@`=zQ15mv-x~Vc|W$2;)Y&0SR02tElEaGQ@$=R7?PltF1uP z#&o-LF-jII3HDZ{rK>FUd&p`K9LkHHwSajn{{<<2SnTs?b4C0V#TikAB=sZ#zg*lv z)Cl4pG0fo47=gVm_|;6v!L0AiKhr0l-R+^U)xpEbjI-(c#h090oREv|FcON_7+H3@ zz}EE$7>*?naKww<93g};RPgaYQ=bPv^gZlVFU@-5p{xp>y@sC8R7@46zQ0l#sVKD1 zwZR)k$2zbfoZ_ex+Nto*h=gaAu9vCP0hr-FULbF{++>32CR@yY;7k$2!&}u#Mi5g_ z#nS9uZ0HTY3qlfj^^;`vFAMEgAi*?WFfgx0@OJm7AK#k}-T~7ph0+g46%%ydCYuKQ zTwC36TXC`7n?|-d_sT5a(`vJ3L>IM=yO_4RDDzTN;EKE?Bp>Uxbv@~%;8T4 zHFo}B@OCDD>pz^anQ#jqYv#vQ^_1K?#@(pZWj>Yp%c?g=?Do|Z22TWJjhL~d;t%f@ z{yJJ?oPYV=pZ;X(R^FM5wBxm}#mfXESc2Pqhp95Vj5I*j%MXk2E}VBmggD!L&DxG} zB<`uK*i8z!*y_g?se+q(HYS~?(#mknIl+MtNt7UXa+qx3020~U|4%$KAU`@P&srwE zBS^CmNYo+m_Ub}j_eRyUXIS<;bUV{0_28UslEcTEt{Wvp1fTytkcHSil5Ko;D7(Qz zYMSMeYa0zX&9aZ)GU`Y6ojlU zrxuSUU6A0{guEi0J!^SpJoqufhE&^()J2a7p$49g)+Z9B3mjN}O3UW`YGe(D-!a{} zerg+yjO^(piz#%KH69AHy8rfRFXK@2i|xcL)Jff)yIHPCZOiXyC4hf^I9*aZ5=gwV z<#_$H(Z&{LSu{We$C*n4^T?*aaQ}F!u(+&osC!)Xoj$ti_&~3FhM-&qTk6L4 zNCI2wKNS4VbeRTa-IIb8N-CV*H6}hzp5q~;88G)x0)!`W!9CG&*AEI0vuF6{l_nZH z-6II29L}T{2!`MUU%IzyK@HBPUsQ{viSpe&pAg+61CfQ{lT_7ul~~j2-Y(2fD3MM| z>V`=<16^lwHXDy?82e^9=X8v`R4=>uG9s^PFX$J5D%h z7vtBZzR_mHZ&IGztF#4ar^+=dUn;(q7TRiKuRaZ&JhR%6TIqSgHIpwp91Pc}|1Vqv z+!M{9f*UVfcHB0v8J9+@f5iy69P&uUi}VB~-JchaN-4);=sL9hvUMyf@kvXI^F52^X_@c>SLRy{r*56)0D6R7iG#NR%evPL;_u!hj1aC{ z_vEq7r{@w}7NMuq(#{6B&JA!VnPBKlgt?d1tG^Z0^2;BT6i33H!9xIid2ZM<2A^ha z?3y@$GI!3Rcfz|{2X;%})`a4O2Cc9fO2+A=Cgwt+Ia<|UsR<;ptdDojMmg&FK=0zB zd$&TQze&Tx41698*BS^|c}YCj#lzq@>~|;1*x{1TI7gj(P-yxi^q2zFCGWecumZ<; z_C=d7=)~(K1(_l*ouc9;UE^2};A%JF{;9eaXw5Xo8*6TTY-@c;8$&x=Xv|iqWHGeN z^TwK&h}2|FHkQ&&;(dx`L^*%4jl$kl=il1iBY3!L#9|{jHd&17pQOiRu)RX`-3y|QUD5DpgoqD- z;r?-A-r{S+(lv#!I6fok>&Tn$znK1c!Etg$$#H59?Tn_(S(EIc9fGK+Xxbl|&u(O0 zkOd>S&U$)$8_r*0N=LSd2CzK%k*0eZH8ba|oNA=BUHB6F7>!>@uhWu1Vyai%h7|2ns%Tb%tvUTZ@i{C;<3< z$9VtUH8=2HRIDZuJ?aeAMMghar^?=KH~$*9WsipQxy1!l=@pTvqZns6$v*}J^MYP7 zSBz9NS_NDOCQTk9uI{kdZ9LRNvC7_{Eo9k?Z~CDl8)6>3iXicZg>zDROt*P>TfZ5_ zD^@P^gDT+N=gEc~cL%prWm)w$MN1m#xvvfR6X)kB;}4@;I*RudMTu|R^daAVozi-W zuY~3!P@MevQu~UUePoEsUW{?V^!wN-gwW(1xO3lz5Bebf> zjdRYuU+$Op`}4lf?|uI3_na=O_*Og0IK^bBe|-n-2jPbWX@|6xooy?)I6L6Ay~Z!Z8cXxJyg7bW&aoJ39grU{Cs zm;63&QFvL{3n8UnG;gFODzbIic6xf=9n!(U%Lz0OIO93yQGKyw7t(7OdNaMSrv%czw&{2@OhGx zwY&Uzl;;K!(`WjfTbks`6+a5YEn@G@jp;11gu7tp{Z44*Pog>(ymA(#qc+yqV_%2~ zx$Dk@Dw7{b4gc|Z7Zg=<O2iu(^_Wp0g>+h zL>0aS_!w7Ngok((Mp7ei|= zL<4!wooyZ4Px(16MWpW4gHi}E7}l?@;VE)0+QM`nU^L`Kx>mz6-R*FQnt`L%9ReP0NqTDI)O zwGORIGov72s*Ltlrtu45*Ym84Z*@XHxv%?Zw&YUMaWBjJgzRQF5r(J#2or;aj=eG&s2D_S)EP z1|2;;!xME#O|zhPMze*8&A2%-{~3Vvt7l~C?5M`|HD9h%Nx0^V0m%YI@F6Q;jdLZL z(UYKYb$dpoEt?z2F4Kt=e5M!pxb$_oS%BLYJ~_>Rf|L1~fx06Tq*uav8xjlVQvZ0l z&a~sVxsK$$2sYZF8eTlh_?O+~h4@6T-dumwroMCEit{mP9lo3F-P-QYmozd_6eCQc z>&(ei_&t1tg^WWfQp-Ax0tTG_n-6u0vj2xI=!Fo!tf%klY%xx(C)~!p2iy+0z(rEu zi{vAq)ZBk|5@iJN0|_=g={O3z0B2TT-%}QzS*?R#VFb2Y{3>E(M~k%U`g?z9bO|d~ zBT43te;R_FYxtZx8p-vH8KRF$v$T=2CGg+qsN<(S8mRZLh6XG8duSW@ULc28d;3$i zpEXjRhE=upV0|B^4KBcktSX@?uhGmI{<-x`~NCVrO$D^gL1TbJw)f<`G=z}X* z^#0uHZ|-~8uoW6+GgdO7+oAbTgCOB`DHb!dox2(gG?00lr9; zeSaw_e{f*sPg#7Av$0hfyQ_rbwIjyhWluSkUJPI4JsO=t4bVg#1|n03VVW_z;tbW% zTfIay#k4#{YtKgmsT`3SceOU4IzVOA0cs$0p=~=&cos!Dp{!7-3izw(z-AfE4Id{f zzZec?fiaj-oZ^G!cOU!qv_X5F+_8FNw5N(;;c5OPMD5&ERcDQ$LI%whG#hYoiK~NC zi^5kdb%_S6Ik^*YAWYC{1zc=UtJ;;-5kW?U{}Sjx*2d~K`@z1g!E0CS^4Bylq?90& z{34c3FXKi){*M_RZa(GPAIc+(;8uxnM0hM|YmGL)rC_Pok1m@ileajPBgQ zrY{5g)a0FjkDU$(rrIpJ1A(FB+PZ-=o4KNE@{HJ}8N+=hK7hMP zTDs9A-p8L@?_{{p)d@=3#xq&hOB=)bnc265bYsjoZEAJ;$SbJjh18@@X8scl`(K96 z^=shDAl(m^_te+h?qZlX`h5e#jS~z7r^FG?Lr!ob-X>#UN?8MXNNP)o`gt^F|?i1>XfF533s+EJP&A^dGhB3~9%iI>5!OxJfKLw@2Eb|e z0O=*TtE{TWUjv4*&iTwQbce`BJW8wv;@6#8wNu3X^jU}^inr9$OS#lgB-nQCp$mSW z^cK`wl2B<92>Y(oX4=*oyc%E5p2Z!~FK8o9PymWaQAJ0vk>AxguQ@<5vKsjgY;4Nb z@D>EMHXFl3v)Z~9__C2aVZ+ie?%58%jQ8Kl;;Ov0eMX@ta?(%%bOzx(ISZuni7h3j zbf3#yU#PZ`DT*2zUffBpTA-X#4k+aQ5gq{vdGGNU8jBykFLvme$X*h1q`Y_J7{72P4hwr)YVrY&U0I zp4<$G2JdM?B&*!tVT2*vSN#A@i>R|oIU>ic>r#^5V) zQ%&i0qS*aHR;`;GdFu0AQP%>m*-JotYx0;1+l)Y;8~-kZ$}7&(&j!$QXZr7?%K>>M z&S{6T{e*Hl13y&2-oRBzYT@>O2s}R5O0%OS?cFNu(RY3BCVmoIb>KX+Z36N%ROUjg z+2|j7?{;+%Lu7p(NaE}yf@R>VT3a4@JCWWVo*J{BVbX<0x|~b>wR&!;%+L=GHw_#u zEgiSvHRGv4+1@1`NUR+&amk)gJb@7GS?kV{vp~LhoS#y8@rH@9op?oyU0`CD>2N16 zZ@I2?B2&JEj}Z-NBsRb)60JQR4IF(=!(=VDSwOWZt_SDbb*GSyD9Y``E*qb;VH3Q> z03Oy<{bp9sj?PvwwP+|jO<^wZp$~8?mKMhB#iWp+#LV;-C0g)FpHQct)I4~s14xcy z(vA@gO6S+y%i-*OwCKVS3IK$1a5y9Y?vL#rHa(|Tp`I}u#8ZyFGwR9k3_(6ik8JN~ zJQk7Pjii*wBN~r&23NvQ&M-$5=Dm_LO&f>5o~oI+#~hAfL^fMgIf#tU5Z`0BveJkh zm=oSPH4nCiOoiCVQt~@0>H8TCTz^i(nO`vsiSfr|JJdLnDQQ2p?E@oPo)%F&&YqrX zi;V69ZzG~w`Vx;7rm&$>hG3&231Nxqq=|8Y({i9#fz5!4(^kb#L$i%M56Smr(~I1} z?tS}*5wYerRa3!kg;j2#S(l#=W?#d3wM_MHUyiRc(N$}Y9InX?vU{LTD@&9N*w8k& zfVnu1CEPTD*_rWh`K6!b-LUFB#dP6so%Vv9AH@$&=OsHEfSmnI*pq8rJ^N%`pZxf6 z=(zK2OG=osqp5|M{RvPPuIkus`n}O>=J~@ykvmg+YGDiJ^KAwwBJP(zHS2))$PV|c yq!%hEc-eo1MnnTopVRP1)digIw#%U|6dOfrwxqp_0yHL4ck1m$)AsB}G5-ge-3|8u literal 0 HcmV?d00001 diff --git a/crates/aurora-curator-core/tests/fixtures/sample.mp4 b/crates/aurora-curator-core/tests/fixtures/sample.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..c08ff3efce2239af054dde775462e564c72dbd20 GIT binary patch literal 18316 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 Option { /// Guesses release-tag metadata (resolution, source, video/audio codec, release /// group) from a filename's own text using local regex patterns only - no network -/// calls, no real media probing (Aurora Curator never ships an ffprobe/MediaInfo dependency, -/// so filename tags are the only source for these fields). Independent of -/// `guess_episode`/`guess_movie`: called alongside whichever of those two applies, -/// since a release name carries (or doesn't carry) these tags regardless of whether -/// it's an episode or a movie. +/// calls, no file I/O. Complements `probe::probe_media_files` below, which reads the +/// same kind of resolution/codec data straight from a file's real container header +/// instead of guessing it from filename text - the Rename panel merges both, +/// preferring an explicit filename tag over a probed value when a file has both. +/// Independent of `guess_episode`/`guess_movie`: called alongside whichever of those +/// two applies, since a release name carries (or doesn't carry) these tags regardless +/// of whether it's an episode or a movie. #[tauri::command] fn guess_release_info(filename: String) -> Option { matching::guess_release_info(&filename) @@ -123,6 +126,7 @@ pub fn run() { sfv::save_checksum_file, sfv::verify_checksum_file, list::export_text_list, + probe::probe_media_files, history::list_rename_history, history::undo_rename_batch, presets::list_presets, diff --git a/src-tauri/src/probe.rs b/src-tauri/src/probe.rs new file mode 100644 index 0000000..b8b6e05 --- /dev/null +++ b/src-tauri/src/probe.rs @@ -0,0 +1,41 @@ +//! The Tauri-facing side of real media-file probing: reads real container metadata +//! (resolution, duration, frame rate, codecs) straight from each loaded file's own +//! header via `aurora_curator_core::probe`, batched the same way `sfv::compute_checksums` +//! batches checksum hashing - one Tauri round trip for the whole loaded set, run off +//! the main thread since this is real file I/O (a probe only reads a container's +//! header/index boxes, not the whole file, but a movie file can still be tens of GB +//! and may live on a slow network share). + +use std::path::Path; + +use aurora_curator_core::probe::{self, MediaProbe}; +use serde::Serialize; + +/// One file's probe result, paired with the path it came from so the frontend can +/// match results back to the loaded items it requested (order isn't otherwise +/// guaranteed to be preserved end to end through `spawn_blocking`). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProbeOutcome { + pub path: String, + pub probe: MediaProbe, +} + +/// Probes a batch of loaded files. Never fails per-file - `aurora_curator_core::probe::probe_media_file` +/// is always best-effort (see its doc comment), so every path in `paths` gets exactly +/// one `ProbeOutcome` back, even a file that isn't a supported container or can't be +/// read at all (its `MediaProbe` just comes back mostly empty). +#[tauri::command] +pub async fn probe_media_files(paths: Vec) -> Vec { + tauri::async_runtime::spawn_blocking(move || { + paths + .into_iter() + .map(|path| { + let probe = probe::probe_media_file(Path::new(&path)); + ProbeOutcome { path, probe } + }) + .collect() + }) + .await + .unwrap_or_default() +} diff --git a/src/components/RenamePanel.tsx b/src/components/RenamePanel.tsx index 165b71e..90b18d7 100644 --- a/src/components/RenamePanel.tsx +++ b/src/components/RenamePanel.tsx @@ -65,6 +65,32 @@ interface ReleaseInfo { group: string | null; } +// Result of real container-header probing (aurora-curator-core's `probe` module, exposed +// via the batched `probe_media_files` Tauri command) - resolution/duration/codecs read +// straight from the file's own header, not guessed from its name. Complements (never +// overrides) `ReleaseInfo` above: a filename-tag value wins whenever a file has both, +// since a release group's own tag is usually deliberate. `resolution`/`duration`/ +// `fileSize` are pre-formatted display strings computed on the Rust side, so this +// component never reimplements that formatting. +interface MediaProbe { + fileSizeBytes: number; + durationSeconds: number | null; + width: number | null; + height: number | null; + frameRate: number | null; + videoCodec: string | null; + audioCodec: string | null; + container: string | null; + resolution: string | null; + duration: string | null; + fileSize: string; +} + +interface ProbeOutcome { + path: string; + probe: MediaProbe; +} + function pad2(n: number): string { return n.toString().padStart(2, "0"); } @@ -623,7 +649,11 @@ export default function RenamePanel() { // movie, never both - see the doc comment on `guess_movie` in aurora-curator-core). Populates // TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings // (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's - // distinct from (and much cheaper than) "Fetch Data" online matching. + // distinct from (and much cheaper than) "Fetch Data" online matching. Alongside all + // of that, also probes each target's real file (aurora-curator-core's `probe` module via the + // batched `probe_media_files` command) for resolution/duration/codecs read straight + // from its container header - see the bindings merge below for how a probed value + // and a filename-tag value combine. const runMatching = async () => { const targets = items.filter((i) => !i.isSample); if (targets.length === 0) { @@ -633,6 +663,12 @@ export default function RenamePanel() { return; } + // One batched round trip for real probing of every target, run alongside the + // per-file filename guessing below rather than after it. + const probePromise = invoke("probe_media_files", { + paths: targets.map((item) => item.id), + }).catch(() => [] as ProbeOutcome[]); + const results: Array = await Promise.all( targets.map(async (item): Promise => { // Release tags (resolution/source/codecs/group) are independent of TV-vs-movie, @@ -666,6 +702,7 @@ export default function RenamePanel() { ); const guesses = new Map(results.map(([id, guess]) => [id, guess] as const)); const releaseGuesses = new Map(results.map(([id, , release]) => [id, release] as const)); + const probes = new Map((await probePromise).map((outcome) => [outcome.path, outcome.probe] as const)); // Count matches from the results directly, not inside the setItems updater below: // React doesn't run a functional updater synchronously, so a counter mutated in // there wouldn't be ready yet for the status message/auto-upgrade check right after. @@ -673,11 +710,13 @@ export default function RenamePanel() { const matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length; const matched = matchedTv + matchedMovie; const tagged = results.filter(([, , release]) => release !== null).length; + const probed = [...probes.values()].filter((probe) => probe.container !== null).length; setItems((prev) => prev.map((item) => { const guess = guesses.get(item.id); const release = releaseGuesses.get(item.id); + const probe = probes.get(item.id); // Release-tag fields apply on top of whatever the TV/movie guess below // contributes (or nothing at all, for a file with no season/episode or movie @@ -692,8 +731,34 @@ export default function RenamePanel() { } : null; + // Real-probed fields: `resolution`/`video codec`/`audio codec` only fill in + // when the filename tag above didn't already provide that field (an explicit + // release-group tag wins over a probed value), but `width`/`height`/`fps`/ + // `duration`/`file size` have no filename-tag equivalent at all, so those + // always apply whenever probing found them. + const probeBindings: Record | null = probe + ? { + "file size": probe.fileSize, + ...(probe.width ? { width: String(probe.width) } : {}), + ...(probe.height ? { height: String(probe.height) } : {}), + ...(probe.frameRate ? { fps: String(Math.round(probe.frameRate * 100) / 100) } : {}), + ...(probe.duration ? { duration: probe.duration } : {}), + ...(!release?.resolution && probe.resolution + ? { vf: probe.resolution, resolution: probe.resolution } + : {}), + ...(!release?.videoCodec && probe.videoCodec + ? { vc: probe.videoCodec, "video codec": probe.videoCodec } + : {}), + ...(!release?.audioCodec && probe.audioCodec + ? { ac: probe.audioCodec, "audio codec": probe.audioCodec } + : {}), + } + : null; + if (!guess) { - return releaseBindings ? { ...item, bindings: { ...item.bindings, ...releaseBindings } } : item; + return releaseBindings || probeBindings + ? { ...item, bindings: { ...item.bindings, ...probeBindings, ...releaseBindings } } + : item; } if (guess.kind === "tv") { @@ -713,6 +778,7 @@ export default function RenamePanel() { "show name": seriesName, "season number": String(episode.season), "episode number": pad2(episode.episode), + ...probeBindings, ...releaseBindings, }, }; @@ -731,6 +797,7 @@ export default function RenamePanel() { // Human-readable aliases. "movie name": title, ...(movie.year ? { year: String(movie.year) } : {}), + ...probeBindings, ...releaseBindings, }, }; @@ -747,7 +814,7 @@ export default function RenamePanel() { setStatus( matched > 0 - ? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}${tagged > 0 ? `, ${tagged} with release tags` : ""}; filename-based, local only).` + ? `Matched ${matched} of ${targets.length} file${targets.length === 1 ? "" : "s"} (${matchedTv} TV, ${matchedMovie} movie${matchedMovie === 1 ? "" : "s"}${tagged > 0 ? `, ${tagged} with release tags` : ""}${probed > 0 ? `, ${probed} probed from file` : ""}; local only, no network calls).` : "No season/episode or movie title recognized in the loaded filenames." ); window.clearTimeout((runMatching as any)._t); @@ -1448,12 +1515,27 @@ export default function RenamePanel() { {"{audio codec}"} {"{release group}"} +
+ + Real file probing, after Match (reads the actual file, local, offline) + + {"{width}"} + {"{height}"} + {"{fps}"} + {"{duration}"} + {"{file size}"} +

Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text like resolution, source (BluRay/HDTV/WEB-DL/…), video/audio codec, and release group — none of this - needs a network call, since it's all parsed straight from the filename itself (Aurora Curator has no video/audio - file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the + needs a network call, since it's all parsed straight from the filename itself. Alongside that, Match also + probes each file's own container header (no ffmpeg/MediaInfo needed) for its real resolution, frame + rate, duration, and codecs — a filename tag wins whenever a file has both, but {"{resolution}"},{" "} + {"{video codec}"}, and {"{audio codec}"} still fill in from the real file when + the filename doesn't carry that tag, and {"{width}"}/{"{height}"}/ + {"{fps}"}/{"{duration}"}/{"{file size}"} are only ever available + this way. Fetch Data resolves the season/episode or movie guess against real online metadata for the real title, episode title, and air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick each independently in Fetch Data Settings (TVmaze is free and needs no credential at