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 0000000..488c000 Binary files /dev/null and b/crates/aurora-curator-core/tests/fixtures/sample.mkv differ 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 0000000..c08ff3e Binary files /dev/null and b/crates/aurora-curator-core/tests/fixtures/sample.mp4 differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 14742d8..bcc2e7d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod history; mod list; mod metadata; mod presets; +mod probe; mod settings; mod sfv; mod subtitles; @@ -47,11 +48,13 @@ fn guess_movie(filename: String) -> 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