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