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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user