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
+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