Add real media-file probing (session 9, sixteenth delivery)

Wires real container-header probing into the Rename panel's Match step, the
first item on the porting roadmap: aurora-curator-core::probe reads actual
resolution, frame rate, duration, and video/audio codecs straight from a
file's own header (mp4 crate for MP4/M4V/MOV, matroska crate for MKV/WebM,
no ffmpeg/MediaInfo dependency), batched through a new probe_media_files
Tauri command. Probed values fill {resolution}/{video codec}/{audio codec}
only when a filename release tag hasn't already, and populate five bindings
with no filename-tag equivalent at all: {width}, {height}, {fps},
{duration}, {file size}. 8 new Rust unit tests against real ffmpeg-generated
fixture files (161 -> 169 total), verified end to end with a 15-check
Playwright script against npm run dev, and README updated (status
paragraph, project layout, a new "Real media-file probing" section, and
roadmap item 1 narrowed to what's still missing).
This commit is contained in:
Renamr Scaffold
2026-08-29 18:07:54 +00:00
parent 03768c398d
commit 1291ae086d
12 changed files with 614 additions and 26 deletions
Generated
+75
View File
@@ -120,8 +120,10 @@ name = "aurora-curator-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"matroska",
"md-5", "md-5",
"mockito", "mockito",
"mp4",
"regex", "regex",
"reqwest 0.12.28", "reqwest 0.12.28",
"rhai", "rhai",
@@ -180,6 +182,15 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -1936,6 +1947,16 @@ dependencies = [
"web_atoms", "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]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@@ -2023,6 +2044,20 @@ dependencies = [
"tokio", "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]] [[package]]
name = "muda" name = "muda"
version = "0.19.3" version = "0.19.3"
@@ -2083,12 +2118,52 @@ dependencies = [
"spin", "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]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" 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]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
+74 -16
View File
@@ -30,9 +30,13 @@ TV episodes and movies
text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…), text straight out of the filename — resolution, source (BluRay/HDTV/WEB-DL/…),
video/audio codec, release group — so naming schemes can reference video/audio codec, release group — so naming schemes can reference
`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or `{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release group}` (or
FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`) without a media prober; the FileBot's own `{vf}`/`{source}`/`{vc}`/`{ac}`/`{group}`), and now also probes each
rest of FileBot's full media-metadata bindings (real probed video/audio stream info) file's own container header for its real resolution, frame rate, duration, and
are not yet ported. The Align Up/Down buttons reorder the (shared) file list — the two 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 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 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 — 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 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` 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/ 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 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 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 into (auto-created) subfolders when
the naming scheme includes `/`), a rename history/undo log the naming scheme includes `/`), a rename history/undo log
(`history/`, a partial `HistoryDialog` port, undo-aware of which (`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 argument parsing/file expansion (`cli/` — no Tauri or network
dependency at all, see "Command-line usage" below). Not yet dependency at all, see "Command-line usage" below), and real
ported: full media metadata bindings, other web datasource media-file container probing (`probe/` — real resolution, frame
clients (TheTVDB/AniDB/etc). No Tauri dependency, so rate, duration, and codecs read straight from a video file's own
it builds and unit-tests standalone. 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 src-tauri/ Tauri app shell: exposes aurora-curator-core as commands to the frontend
(naming-scheme rendering, filename matching for both TV and (naming-scheme rendering, filename matching for both TV and
movies, metadata fetch — `metadata.rs` for TV episodes (against 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 whatever already-rendered, newline-joined text the frontend hands
it to a `.txt` file — the only new Tauri surface List needed, since it to a `.txt` file — the only new Tauri surface List needed, since
matching each file and rendering its line both reuse existing 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` command-line mode's orchestration: builds and executes a
rename plan against `aurora-curator-core::cli`'s parsed arguments, rename plan against `aurora-curator-core::cli`'s parsed arguments,
with no Tauri `App`/window ever created for it — `main.rs` branches 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 src/ React + TypeScript UI (Vite). Faithful recreation of the original
two-pane rename screen (Aurora theme) wired to real file loading, two-pane rename screen (Aurora theme) wired to real file loading,
drag-and-drop, live naming-scheme preview, local TV/movie 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 modal holding independent TV-source (TMDB/TVmaze/OMDb) and
movie-source (TMDB/OMDb) pickers plus the TMDB token and OMDb key movie-source (TMDB/OMDb) pickers plus the TMDB token and OMDb key
inputs), a 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 File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text
to disk. 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) ## Porting roadmap (not yet done)
Roughly in priority order — each references the original FileBot source file it's 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): 1. **Full naming-scheme bindings** — `net.filebot.format.MediaBindingBean` (1354 lines):
all the `{n}`, `{s00e00}`, `{airdate}`, `{vf}` (video format), etc. binding 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}`, (`guess_release_info` in `crates/aurora-curator-core/src/matching`): `{vf}`/`{resolution}`,
`{source}`, `{vc}`/`{video codec}`, `{ac}`/`{audio codec}`, `{group}`/`{release `{source}`, `{vc}`/`{video codec}`, `{ac}`/`{audio codec}`, `{group}`/`{release
group}`. What's still missing is everything MediaBindingBean derives from actually group}`. Real container probing (`crates/aurora-curator-core/src/probe`, see "Real
*probing* the media file (real video/audio stream resolution, duration, bitrate, media-file probing" below) now covers the other half MediaBindingBean derives from
frame rate, file size/hash, etc.) — Aurora Curator ships no ffprobe/MediaInfo dependency, so actually reading the file — real resolution/video codec/audio codec fill in
those bindings only work when a release name happens to carry the info as text. 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` 2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods`
(636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`. (636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`.
Only a small subset (`any`, `all_of`, `concat`) is ported so far Only a small subset (`any`, `all_of`, `concat`) is ported so far
+7
View File
@@ -31,6 +31,13 @@ crc32fast = "1"
md-5 = "0.10" md-5 = "0.10"
sha1 = "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] [dev-dependencies]
# Local HTTP mock server so the TMDB client's request/response handling can be unit # 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. # tested without live network access or a real API key.
+1
View File
@@ -9,6 +9,7 @@ pub mod cli;
pub mod format; pub mod format;
pub mod history; pub mod history;
pub mod matching; pub mod matching;
pub mod probe;
pub mod rename; pub mod rename;
pub mod sfv; pub mod sfv;
pub mod similarity; pub mod similarity;
+318
View File
@@ -0,0 +1,318 @@
//! Real media-file probing: reads actual container metadata (resolution, duration,
//! frame rate, codecs) straight from a video file's own header, rather than guessing
//! from the filename the way `matching::guess_release_info` does. This is the piece
//! the README's porting roadmap calls out as still missing from `MediaBindingBean`
//! parity - "everything MediaBindingBean derives from actually probing the media
//! file" - and it's meant to complement filename-tag guessing, not replace it: the
//! caller (the Rename panel's binding merge) only uses a probed value for a field the
//! filename didn't already carry a tag for.
//!
//! No ffmpeg/MediaInfo system dependency is needed - two pure-Rust container parsers
//! do the actual reading: the `mp4` crate for ISO-BMFF containers (.mp4/.m4v/.mov)
//! and the `matroska` crate for EBML/Matroska containers (.mkv/.webm). Anything else
//! (.avi, .wmv, an unrecognized extension, or a file that fails to parse as its
//! container) yields a [`MediaProbe`] with only `file_size_bytes` populated - probing
//! is always a best-effort enrichment layered on top of filename matching, never a
//! hard requirement, so a corrupt or unsupported file must never error the caller out.
use serde::Serialize;
use std::path::Path;
use std::time::Duration;
/// Everything real container probing could determine about one media file. Every
/// field but `file_size_bytes` is `None` when the file isn't a supported container,
/// couldn't be parsed, or simply doesn't carry that particular piece of information
/// (e.g. a video track with no frame-rate box).
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MediaProbe {
pub file_size_bytes: u64,
pub duration_seconds: Option<f64>,
pub width: Option<u32>,
pub height: Option<u32>,
pub frame_rate: Option<f64>,
pub video_codec: Option<String>,
pub audio_codec: Option<String>,
pub container: Option<&'static str>,
// Pre-formatted display labels for the three fields above that a naming-scheme
// binding actually wants human-readable rather than raw - computed here (not in
// the UI layer) so there's exactly one place that knows how to format them, shared
// by every caller. `resolution` mirrors `matching::ReleaseInfo::resolution`'s
// "1080p"-style bucketing so a probed value and a filename-tag value read
// identically; `file_size` is always present since `file_size_bytes` always is.
pub resolution: Option<String>,
pub duration: Option<String>,
pub file_size: String,
}
/// Probes one file by its extension. Never fails - a file that can't be opened, isn't
/// a recognized container, or fails to parse just yields a mostly-empty [`MediaProbe`]
/// (still carrying a real `file_size_bytes`/`file_size` when the file itself is readable).
pub fn probe_media_file(path: &Path) -> MediaProbe {
let file_size_bytes = std::fs::metadata(path).map(|m| m.len()).unwrap_or(0);
let mut probe = MediaProbe {
file_size_bytes,
file_size: file_size_label(file_size_bytes),
..Default::default()
};
let ext = path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
match ext.as_deref() {
Some("mp4") | Some("m4v") | Some("mov") => probe_mp4(path, &mut probe),
Some("mkv") | Some("webm") => probe_matroska(path, &mut probe),
_ => {}
}
if let Some(height) = probe.height {
probe.resolution = Some(resolution_label(height).to_string());
}
if let Some(seconds) = probe.duration_seconds {
probe.duration = Some(duration_label(seconds));
}
probe
}
fn probe_mp4(path: &Path, probe: &mut MediaProbe) {
let Ok(file) = std::fs::File::open(path) else { return };
let Ok(size) = file.metadata().map(|m| m.len()) else { return };
let reader = std::io::BufReader::new(file);
let Ok(reader) = mp4::Mp4Reader::read_header(reader, size) else { return };
probe.container = Some("MP4");
let total = reader.duration();
if total > Duration::ZERO {
probe.duration_seconds = Some(total.as_secs_f64());
}
for track in reader.tracks().values() {
match track.track_type() {
Ok(mp4::TrackType::Video) => {
let (w, h) = (track.width(), track.height());
if w > 0 && h > 0 {
probe.width = Some(w as u32);
probe.height = Some(h as u32);
}
let fps = track.frame_rate();
if fps > 0.0 {
probe.frame_rate = Some(fps);
}
if let Ok(media_type) = track.media_type() {
probe.video_codec = Some(mp4_codec_label(media_type));
}
}
Ok(mp4::TrackType::Audio) => {
if probe.audio_codec.is_none() {
if let Ok(media_type) = track.media_type() {
probe.audio_codec = Some(mp4_codec_label(media_type));
}
}
}
_ => {}
}
}
}
fn probe_matroska(path: &Path, probe: &mut MediaProbe) {
let Ok(mat) = matroska::open(path) else { return };
probe.container = Some("Matroska");
if let Some(duration) = mat.info.duration {
probe.duration_seconds = Some(duration.as_secs_f64());
}
for track in &mat.tracks {
match &track.settings {
matroska::Settings::Video(video) => {
if video.pixel_width > 0 && video.pixel_height > 0 {
probe.width = Some(video.pixel_width as u32);
probe.height = Some(video.pixel_height as u32);
}
probe.video_codec = Some(codec_label(&track.codec_id));
}
matroska::Settings::Audio(_) => {
if probe.audio_codec.is_none() {
probe.audio_codec = Some(codec_label(&track.codec_id));
}
}
matroska::Settings::None => {}
}
}
}
/// Friendly label for an mp4 track's [`mp4::MediaType`] - matches the short labels
/// `codec_label` below produces from a Matroska codec ID, so a probed codec reads the
/// same way regardless of which container it came from.
fn mp4_codec_label(media_type: mp4::MediaType) -> String {
match media_type {
mp4::MediaType::H264 => "H.264".to_string(),
mp4::MediaType::H265 => "H.265".to_string(),
mp4::MediaType::VP9 => "VP9".to_string(),
mp4::MediaType::AAC => "AAC".to_string(),
mp4::MediaType::TTXT => "Text".to_string(),
}
}
/// Matroska codec IDs (see <https://www.matroska.org/technical/codec_specs.html>) are
/// machine-readable strings like `"V_MPEG4/ISO/AVC"` rather than a friendly name - map
/// the ones scene releases actually use to the same short labels
/// `matching::guess_release_info` already produces from filename tags. An unrecognized
/// codec ID is passed through as-is rather than dropped, so it's still visible even if
/// not prettified.
fn codec_label(codec_id: &str) -> String {
match codec_id {
"V_MPEG4/ISO/AVC" => "H.264".to_string(),
"V_MPEGH/ISO/HEVC" => "H.265".to_string(),
"V_VP9" => "VP9".to_string(),
"V_VP8" => "VP8".to_string(),
"V_AV1" => "AV1".to_string(),
"V_MPEG4/ISO/ASP" => "XviD".to_string(),
"A_AAC" | "A_AAC/MPEG4/LC" => "AAC".to_string(),
"A_AC3" => "AC3".to_string(),
"A_EAC3" => "E-AC3".to_string(),
"A_DTS" => "DTS".to_string(),
"A_TRUEHD" => "TrueHD".to_string(),
"A_FLAC" => "FLAC".to_string(),
"A_OPUS" => "Opus".to_string(),
"A_VORBIS" => "Vorbis".to_string(),
"A_MPEG/L3" => "MP3".to_string(),
other => other.to_string(),
}
}
/// Buckets a real probed pixel height into the same standard-resolution labels
/// `matching::guess_release_info`'s filename-tag parsing recognizes (`"1080p"`,
/// `"720p"`, ...), so a probed resolution and a filename-tag resolution read
/// identically in a naming scheme's `{resolution}`/`{vf}` binding.
pub fn resolution_label(height: u32) -> &'static str {
if height >= 2000 {
"2160p"
} else if height >= 1000 {
"1080p"
} else if height >= 700 {
"720p"
} else if height >= 540 {
"576p"
} else if height >= 400 {
"480p"
} else {
"SD"
}
}
/// Formats a duration in seconds as `h:mm:ss` (or `m:ss` under an hour), for a
/// `{duration}` binding.
pub fn duration_label(seconds: f64) -> String {
let total = seconds.round() as u64;
let h = total / 3600;
let m = (total % 3600) / 60;
let s = total % 60;
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
/// Formats a byte count as a human-readable size (`"1.4 GB"`, `"512 MB"`, ...), for a
/// `{file size}` binding.
pub fn file_size_label(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} {}", UNITS[unit])
} else {
format!("{size:.1} {}", UNITS[unit])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> std::path::PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name)
}
#[test]
fn probes_a_real_mp4_file() {
let probe = probe_media_file(&fixture("sample.mp4"));
assert_eq!(probe.container, Some("MP4"));
assert_eq!(probe.width, Some(320));
assert_eq!(probe.height, Some(240));
assert_eq!(probe.video_codec.as_deref(), Some("H.264"));
assert_eq!(probe.audio_codec.as_deref(), Some("AAC"));
assert!(probe.duration_seconds.unwrap() > 0.5);
assert!(probe.file_size_bytes > 0);
assert_eq!(probe.resolution.as_deref(), Some("SD")); // 240px tall - below the 480p bucket
assert_eq!(probe.duration.as_deref(), Some("0:01"));
assert!(!probe.file_size.is_empty());
}
#[test]
fn probes_a_real_matroska_file() {
let probe = probe_media_file(&fixture("sample.mkv"));
assert_eq!(probe.container, Some("Matroska"));
assert_eq!(probe.width, Some(640));
assert_eq!(probe.height, Some(360));
assert_eq!(probe.video_codec.as_deref(), Some("H.264"));
assert_eq!(probe.audio_codec.as_deref(), Some("AAC"));
assert!(probe.duration_seconds.unwrap() > 0.5);
assert_eq!(probe.resolution.as_deref(), Some("SD")); // 360px tall - below the 480p bucket
}
#[test]
fn non_media_file_yields_only_file_size() {
let probe = probe_media_file(&fixture("not_media.txt"));
assert_eq!(probe.container, None);
assert_eq!(probe.width, None);
assert_eq!(probe.resolution, None);
assert_eq!(probe.duration, None);
assert!(probe.file_size_bytes > 0);
assert!(!probe.file_size.is_empty()); // file_size is always computed from the real byte count
}
#[test]
fn corrupt_container_degrades_gracefully() {
let probe = probe_media_file(&fixture("corrupt.mp4"));
assert_eq!(probe.container, None);
assert_eq!(probe.width, None);
assert!(probe.file_size_bytes > 0);
}
#[test]
fn missing_file_yields_all_none() {
let probe = probe_media_file(Path::new("/does/not/exist.mp4"));
assert_eq!(probe.file_size_bytes, 0);
assert_eq!(probe.container, None);
assert_eq!(probe.file_size, "0 B");
}
#[test]
fn resolution_label_buckets_common_heights() {
assert_eq!(resolution_label(2160), "2160p");
assert_eq!(resolution_label(1080), "1080p");
assert_eq!(resolution_label(720), "720p");
assert_eq!(resolution_label(480), "480p");
assert_eq!(resolution_label(240), "SD");
}
#[test]
fn duration_label_formats_with_and_without_hours() {
assert_eq!(duration_label(45.0), "0:45");
assert_eq!(duration_label(125.0), "2:05");
assert_eq!(duration_label(3725.0), "1:02:05");
}
#[test]
fn file_size_label_formats_common_magnitudes() {
assert_eq!(file_size_label(512), "512 B");
assert_eq!(file_size_label(1536), "1.5 KB");
assert_eq!(file_size_label(1_500_000_000), "1.4 GB");
}
}
+1
View File
@@ -0,0 +1 @@
not actually a valid mp4 container, just bytes with an .mp4 extension
@@ -0,0 +1 @@
this is not a media file, just plain text used to test that probing degrades gracefully
Binary file not shown.
Binary file not shown.
+9 -5
View File
@@ -5,6 +5,7 @@ mod history;
mod list; mod list;
mod metadata; mod metadata;
mod presets; mod presets;
mod probe;
mod settings; mod settings;
mod sfv; mod sfv;
mod subtitles; mod subtitles;
@@ -47,11 +48,13 @@ fn guess_movie(filename: String) -> Option<MovieGuess> {
/// Guesses release-tag metadata (resolution, source, video/audio codec, release /// Guesses release-tag metadata (resolution, source, video/audio codec, release
/// group) from a filename's own text using local regex patterns only - no network /// 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, /// calls, no file I/O. Complements `probe::probe_media_files` below, which reads the
/// so filename tags are the only source for these fields). Independent of /// same kind of resolution/codec data straight from a file's real container header
/// `guess_episode`/`guess_movie`: called alongside whichever of those two applies, /// instead of guessing it from filename text - the Rename panel merges both,
/// since a release name carries (or doesn't carry) these tags regardless of whether /// preferring an explicit filename tag over a probed value when a file has both.
/// it's an episode or a movie. /// 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] #[tauri::command]
fn guess_release_info(filename: String) -> Option<ReleaseInfo> { fn guess_release_info(filename: String) -> Option<ReleaseInfo> {
matching::guess_release_info(&filename) matching::guess_release_info(&filename)
@@ -123,6 +126,7 @@ pub fn run() {
sfv::save_checksum_file, sfv::save_checksum_file,
sfv::verify_checksum_file, sfv::verify_checksum_file,
list::export_text_list, list::export_text_list,
probe::probe_media_files,
history::list_rename_history, history::list_rename_history,
history::undo_rename_batch, history::undo_rename_batch,
presets::list_presets, presets::list_presets,
+41
View File
@@ -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<String>) -> Vec<ProbeOutcome> {
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()
}
+87 -5
View File
@@ -65,6 +65,32 @@ interface ReleaseInfo {
group: string | null; 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 { function pad2(n: number): string {
return n.toString().padStart(2, "0"); 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 // 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 // TV bindings (`{n}`/`{s00e00}`/`{season}`/`{episode}`/etc.) or movie bindings
// (`{n}`/`{y}`/etc.) accordingly - no network calls, no metadata lookup, so it's // (`{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 runMatching = async () => {
const targets = items.filter((i) => !i.isSample); const targets = items.filter((i) => !i.isSample);
if (targets.length === 0) { if (targets.length === 0) {
@@ -633,6 +663,12 @@ export default function RenamePanel() {
return; 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<ProbeOutcome[]>("probe_media_files", {
paths: targets.map((item) => item.id),
}).catch(() => [] as ProbeOutcome[]);
const results: Array<readonly [string, MatchGuess | null, ReleaseInfo | null]> = await Promise.all( const results: Array<readonly [string, MatchGuess | null, ReleaseInfo | null]> = await Promise.all(
targets.map(async (item): Promise<readonly [string, MatchGuess | null, ReleaseInfo | null]> => { targets.map(async (item): Promise<readonly [string, MatchGuess | null, ReleaseInfo | null]> => {
// Release tags (resolution/source/codecs/group) are independent of TV-vs-movie, // 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 guesses = new Map(results.map(([id, guess]) => [id, guess] as const));
const releaseGuesses = new Map(results.map(([id, , release]) => [id, release] 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: // 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 // 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. // 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 matchedMovie = results.filter(([, guess]) => guess?.kind === "movie").length;
const matched = matchedTv + matchedMovie; const matched = matchedTv + matchedMovie;
const tagged = results.filter(([, , release]) => release !== null).length; const tagged = results.filter(([, , release]) => release !== null).length;
const probed = [...probes.values()].filter((probe) => probe.container !== null).length;
setItems((prev) => setItems((prev) =>
prev.map((item) => { prev.map((item) => {
const guess = guesses.get(item.id); const guess = guesses.get(item.id);
const release = releaseGuesses.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 // 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 // contributes (or nothing at all, for a file with no season/episode or movie
@@ -692,8 +731,34 @@ export default function RenamePanel() {
} }
: null; : 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<string, string> | 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) { if (!guess) {
return releaseBindings ? { ...item, bindings: { ...item.bindings, ...releaseBindings } } : item; return releaseBindings || probeBindings
? { ...item, bindings: { ...item.bindings, ...probeBindings, ...releaseBindings } }
: item;
} }
if (guess.kind === "tv") { if (guess.kind === "tv") {
@@ -713,6 +778,7 @@ export default function RenamePanel() {
"show name": seriesName, "show name": seriesName,
"season number": String(episode.season), "season number": String(episode.season),
"episode number": pad2(episode.episode), "episode number": pad2(episode.episode),
...probeBindings,
...releaseBindings, ...releaseBindings,
}, },
}; };
@@ -731,6 +797,7 @@ export default function RenamePanel() {
// Human-readable aliases. // Human-readable aliases.
"movie name": title, "movie name": title,
...(movie.year ? { year: String(movie.year) } : {}), ...(movie.year ? { year: String(movie.year) } : {}),
...probeBindings,
...releaseBindings, ...releaseBindings,
}, },
}; };
@@ -747,7 +814,7 @@ export default function RenamePanel() {
setStatus( setStatus(
matched > 0 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." : "No season/episode or movie title recognized in the loaded filenames."
); );
window.clearTimeout((runMatching as any)._t); window.clearTimeout((runMatching as any)._t);
@@ -1448,12 +1515,27 @@ export default function RenamePanel() {
<code>{"{audio codec}"}</code> <code>{"{audio codec}"}</code>
<code>{"{release group}"}</code> <code>{"{release group}"}</code>
</div> </div>
<div className="format-fields-group">
<span className="format-fields-group-title">
Real file probing, after <strong>Match</strong> (reads the actual file, local, offline)
</span>
<code>{"{width}"}</code>
<code>{"{height}"}</code>
<code>{"{fps}"}</code>
<code>{"{duration}"}</code>
<code>{"{file size}"}</code>
</div>
</div> </div>
<p className="format-hint"> <p className="format-hint">
Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text 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 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 needs a network call, since it's all parsed straight from the filename itself. Alongside that, Match also
file prober, so a tag that isn't in the filename text just won't be available). Fetch Data resolves the 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 <code>{"{resolution}"}</code>,{" "}
<code>{"{video codec}"}</code>, and <code>{"{audio codec}"}</code> still fill in from the real file when
the filename doesn't carry that tag, and <code>{"{width}"}</code>/<code>{"{height}"}</code>/
<code>{"{fps}"}</code>/<code>{"{duration}"}</code>/<code>{"{file size}"}</code> 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 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 air/release date. TV episodes can come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick
each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at each independently in <strong>Fetch Data Settings</strong> (TVmaze is free and needs no credential at