From e219e8f2a867b42381f2b4a6a6e83c22efcbaadf Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sun, 30 Aug 2026 03:48:22 +0000 Subject: [PATCH] Wire real media probing into Filter and List panels Extends the sixteenth delivery's container-header probing (probe_media_files) beyond the Rename panel: Filter and List now run the same batched probe alongside their existing local scan, using the same override rule everywhere - a filename tag wins when present, otherwise a real probed value fills in. List: {width}/{height}/{fps}/{duration}/{file size} are now available in naming-scheme-style expressions (no filename-tag equivalent exists), and a probed {resolution}/{video codec}/{audio codec} fills in when the filename didn't already carry that tag. Filter: gained Resolution and Video codec chip rows (populated from the same merged filename-tag-or-probed value, with an Unknown bucket for files with neither) so a batch can be narrowed by either. Each result row's subtitle now also shows the resolved resolution when one exists. Verified via cargo check/test --workspace (unaffected, frontend-only change) + npm run build (clean) + a 15-check Playwright script against npm run dev covering both panels: filename-tag resolution winning over a conflicting probed value, probed fields filling in for an otherwise- unmatched file, the new Filter chip rows narrowing results correctly. Updated README (status paragraph, Filter section, List section, Real media-file probing section, roadmap item 1). --- README.md | 110 ++++++++++++++---------- src/components/FilterPanel.tsx | 153 +++++++++++++++++++++++++++++++-- src/components/ListPanel.tsx | 78 +++++++++++++++-- 3 files changed, 283 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 9816ad7..c4dd7c7 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,9 @@ file via a native Save dialog — this closes out the full original panel set (R 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. The naming-expression engine's Rhai +"Match" step, and now the Filter and List panels' own local scans too — Filter gained +Resolution/Video codec filter chips from it, and List's expressions can reference the +same probed fields — see "Real media-file probing" below. The naming-expression engine's Rhai scripting layer also gained a batch of FileBot-style string helpers — `.pad()`, `.sort_name()`/`.sort_initial()`, `.upper_initial()`/`.lower_initial()`, `.acronym()`, `.validate_file_name()`, `.space()`/`.space_with()`, `.colon()`/`.colon_with()` — callable @@ -500,21 +502,29 @@ write hex digests in uppercase and others in lowercase. The Filter sidebar panel loads a batch of files (independent of whatever's loaded in the Rename panel) and narrows it down before you decide what to do with it elsewhere — copy -the matching paths out, or just get a sense of what's actually in a folder. Unlike SFV, -it adds no new Rust/Tauri code at all: it scans every loaded file with the exact same -local, filename-only matchers "Match" uses (`guess_episode`/`guess_movie`/ -`guess_release_info`) and turns the results into filter criteria instead of a rename -preview — no network calls, so scanning even a large batch is effectively instant. +the matching paths out, or just get a sense of what's actually in a folder. It scans every +loaded file with the exact same local, filename-only matchers "Match" uses +(`guess_episode`/`guess_movie`/`guess_release_info`) and turns the results into filter +criteria instead of a rename preview — no network calls, so scanning even a large batch is +effectively instant. It also runs the same batched `probe_media_files` real container-header +probe "Match" and List do (see "Real media-file probing" below), so a file's resolution/ +video codec can come from real probing even when nothing in its filename says so. **Load Files…** loads and scans a batch in one step. Toggleable chips then appear for every **extension** actually present (e.g. MKV/MP4/TXT) and every **kind** Aurora Curator locally recognized (TV/Movies/Unmatched) — click a chip to exclude that group, click it -again to bring it back. A **filename contains** field does a live, case-insensitive +again to bring it back. **Resolution** and **Video codec** chip rows appear the same way +whenever at least one loaded file actually has a value for either (a filename tag wins +when present, otherwise a real probed value fills in — same override rule as everywhere +else this delivery touches; files with neither fall into their own "Unknown" chip rather +than being silently excluded). A **filename contains** field does a live, case-insensitive substring search, and a **season** field (shown only while the TV chip is enabled) narrows to a single season number, automatically excluding anything that isn't a TV episode in -that season. The results list updates live with an "N of M files match" count, and -**Copy File Paths** writes every currently-matching file's full path, one per line, to the -clipboard. +that season. Each result row's subtitle also shows a resolved resolution when one exists +(e.g. `Unmatched · 480p` for a file Match couldn't identify as TV or a movie but still got +a real probed resolution for). The results list updates live with an "N of M files match" +count, and **Copy File Paths** writes every currently-matching file's full path, one per +line, to the clipboard. ## List @@ -530,26 +540,31 @@ native Save dialog and writes whatever text the frontend hands it to a `.txt` fi everything else (matching, and rendering each file's line) reuses commands that already existed. -**Load Files…** loads a batch and scans it locally, the same way Filter does. The -**Format** field holds the expression rendered against each file — the exact same `{ }` -syntax the naming scheme uses, run through the same `render_expression` command the -Rename panel's live preview calls. `{name}`/`{ext}` always work (they're the file's own -name/extension); `{show name}`/`{season number}`/`{episode number}`, `{movie name}`/ -`{year}`, and the release-tag fields (`{resolution}`/`{source}`/`{video codec}`/ -`{audio codec}`/`{release group}`) fill in once the local scan recognizes a file — there's -no Fetch Data step here, List stays local-only like Filter. A file whose rendered line -comes out empty (e.g. an expression that only references a field this particular file -never matched) shows an "(empty)" placeholder rather than blocking the rest of the list, -and the results header tracks "N of M lines rendered" live as the expression changes. -**Copy List** copies every line, in file order, newline-joined, to the clipboard; **Export -to File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text -to disk. +**Load Files…** loads a batch and scans it locally, the same way Filter does — including +the same batched real container-header probe (see "Real media-file probing" below), so +List's expressions get real probed values too, not just filename-tag ones. The **Format** +field holds the expression rendered against each file — the exact same `{ }` syntax the +naming scheme uses, run through the same `render_expression` command the Rename panel's +live preview calls. `{name}`/`{ext}` always work (they're the file's own name/extension); +`{show name}`/`{season number}`/`{episode number}`, `{movie name}`/`{year}`, and the +release-tag fields (`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release +group}`) fill in once the local scan recognizes a file; `{width}`/`{height}`/`{fps}`/ +`{duration}`/`{file size}` come straight from real probing (no filename-tag equivalent +exists), and a probed `{resolution}`/`{video codec}`/`{audio codec}` fills in whenever the +filename didn't already carry that tag — there's no Fetch Data step here, List stays +local-only like Filter. A file whose rendered line comes out empty (e.g. an expression +that only references a field this particular file never matched) shows an "(empty)" +placeholder rather than blocking the rest of the list, and the results header tracks +"N of M lines rendered" live as the expression changes. **Copy List** copies every line, in +file order, newline-joined, to the clipboard; **Export 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 +binding source wired into the Rename panel's "Match" step, the Filter panel's scan, and +the List panel's scan alike, 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 @@ -562,19 +577,24 @@ 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. +Clicking **Match** (Rename), or loading a batch into **Filter** or **List**, 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 — all +three panels call the exact same command the exact same way. 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. Rename's Match status line, List's scan status, and Filter's +scan status all report how many loaded files were actually probed (`"N probed from +file"`), the naming-scheme editor's field-reference list has a "Real file probing" group +listing all five width/height/fps/duration/file-size bindings, and the Filter panel adds +its own **Resolution**/**Video codec** chip rows (populated from the same merged +filename-tag-or-probed value) so a batch can be narrowed by either, not just by +extension/kind/name/season. ## Porting roadmap (not yet done) @@ -591,11 +611,13 @@ ported from, for anyone picking this up: 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). + for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Probed + bindings are now wired into all three panels that do local matching — Rename's + Match step, and the Filter and List panels' own scans (Filter also gained + Resolution/Video codec filter chips from it). Still missing: probing for other + containers (.avi, .wmv, ...), audio-only metadata (bitrate, sample rate, channel + layout), and file hashing (`{crc32}` reuses the SFV panel's own hasher today but + isn't wired into naming-scheme bindings yet). 2. **String/expression helper methods** — `net.filebot.format.ExpressionFormatMethods` (636 lines): FileBot-specific Groovy string extensions like `.pad()`, `.sortName()`. Ported so far (`crates/aurora-curator-core/src/format/functions.rs`): `any`, `all_of`, diff --git a/src/components/FilterPanel.tsx b/src/components/FilterPanel.tsx index 29248bf..72d99ee 100644 --- a/src/components/FilterPanel.tsx +++ b/src/components/FilterPanel.tsx @@ -29,6 +29,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/codecs read straight +// from the file's own header, not guessed from its name. Complements (never overrides) +// `ReleaseInfo` above, same as the Rename and List panels: a filename-tag value wins +// whenever a file has both. +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; +} + +const UNKNOWN = "(unknown)"; + type MediaKind = "tv" | "movie" | "unmatched"; interface FileInfo { @@ -39,6 +65,12 @@ interface FileInfo { movieName?: string; year?: number | null; release: ReleaseInfo | null; + // Merged resolution/video codec - a filename tag wins when present, otherwise falls + // back to real container probing. Every file gets a chance at both, including an + // otherwise-unmatched one (see `scan` below). + resolution: string | null; + videoCodec: string | null; + probed: boolean; } const KIND_LABELS: Record = { @@ -57,14 +89,15 @@ function displayName(file: FileEntry): string { function subtitleFor(info: FileInfo | undefined): string { if (!info) return ""; + const tag = info.resolution ? ` · ${info.resolution}` : ""; if (info.kind === "tv") { const se = `S${String(info.season ?? 0).padStart(2, "0")}E${String(info.episode ?? 0).padStart(2, "0")}`; - return info.seriesName ? `${info.seriesName} · ${se}` : se; + return (info.seriesName ? `${info.seriesName} · ${se}` : se) + tag; } if (info.kind === "movie") { - return info.year ? `${info.movieName} (${info.year})` : info.movieName ?? ""; + return (info.year ? `${info.movieName} (${info.year})` : info.movieName ?? "") + tag; } - return "Unmatched"; + return "Unmatched" + tag; } export default function FilterPanel() { @@ -73,6 +106,8 @@ export default function FilterPanel() { const [scanning, setScanning] = useState(false); const [extensions, setExtensions] = useState>(new Set()); const [kinds, setKinds] = useState>(new Set()); + const [resolutions, setResolutions] = useState>(new Set()); + const [videoCodecs, setVideoCodecs] = useState>(new Set()); const [nameContains, setNameContains] = useState(""); const [season, setSeason] = useState(""); const [status, setStatus] = useState(null); @@ -91,6 +126,13 @@ export default function FilterPanel() { async function scan(targets: FileEntry[]) { setScanning(true); try { + // One batched round trip for real probing of every target, run alongside the + // per-file filename guessing below rather than after it - same pattern as the + // Rename and List panels. + const probePromise = invoke("probe_media_files", { + paths: targets.map((file) => file.path), + }).catch(() => [] as ProbeOutcome[]); + const results = await Promise.all( targets.map(async (file): Promise => { const releasePromise = invoke("guess_release_info", { @@ -107,6 +149,9 @@ export default function FilterPanel() { season: episode.season, episode: episode.episode, release: null, + resolution: null, + videoCodec: null, + probed: false, }; } } catch { @@ -116,31 +161,61 @@ export default function FilterPanel() { try { const movie = await invoke("guess_movie", { filename: displayName(file) }); if (movie) { - fileInfo = { kind: "movie", movieName: movie.title, year: movie.year, release: null }; + fileInfo = { + kind: "movie", + movieName: movie.title, + year: movie.year, + release: null, + resolution: null, + videoCodec: null, + probed: false, + }; } } catch { // Neither guess worked. } } - if (!fileInfo) fileInfo = { kind: "unmatched", release: null }; + if (!fileInfo) { + fileInfo = { kind: "unmatched", release: null, resolution: null, videoCodec: null, probed: false }; + } fileInfo.release = await releasePromise; return [file.path, fileInfo] as const; }) ); + const probes = new Map((await probePromise).map((outcome) => [outcome.path, outcome.probe] as const)); + const nextInfo: Record = {}; const seenExt = new Set(); const seenKinds = new Set(); + const seenRes = new Set(); + const seenCodec = new Set(); + let probedCount = 0; for (const [path, fileInfo] of results) { + const probe = probes.get(path); + // Real-probed resolution/video codec only fill in when the filename tag + // above didn't already provide them - same override rule the Rename and List + // panels use. A file with neither still gets a chance at both, even one + // Match couldn't recognize as TV or a movie at all. + fileInfo.resolution = fileInfo.release?.resolution ?? probe?.resolution ?? null; + fileInfo.videoCodec = fileInfo.release?.videoCodec ?? probe?.videoCodec ?? null; + fileInfo.probed = probe?.container != null; + if (fileInfo.probed) probedCount += 1; nextInfo[path] = fileInfo; seenKinds.add(fileInfo.kind); + seenRes.add(fileInfo.resolution ?? UNKNOWN); + seenCodec.add(fileInfo.videoCodec ?? UNKNOWN); } for (const file of targets) seenExt.add(file.ext); setInfo(nextInfo); setExtensions(seenExt); setKinds(seenKinds); - setStatus(`Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (no network calls).`); + setResolutions(seenRes); + setVideoCodecs(seenCodec); + setStatus( + `Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (${probedCount > 0 ? `${probedCount} probed from file; ` : ""}no network calls).` + ); } finally { setScanning(false); } @@ -168,10 +243,33 @@ export default function FilterPanel() { }); } + function toggleResolution(res: string) { + setResolutions((prev) => { + const next = new Set(prev); + if (next.has(res)) next.delete(res); + else next.add(res); + return next; + }); + } + + function toggleVideoCodec(codec: string) { + setVideoCodecs((prev) => { + const next = new Set(prev); + if (next.has(codec)) next.delete(codec); + else next.add(codec); + return next; + }); + } + const allExtensions = Array.from(new Set(files.map((f) => f.ext))).sort(); const allKinds = (["tv", "movie", "unmatched"] as MediaKind[]).filter((k) => Object.values(info).some((i) => i.kind === k) ); + // Real container probing is best-effort, so these rows only show up once something + // actually has a real value - a batch of files with no recognizable resolution/codec + // at all (e.g. non-media files) doesn't get a useless "Unknown"-only row. + const allResolutions = Array.from(new Set(Object.values(info).map((i) => i.resolution ?? UNKNOWN))).sort(); + const allVideoCodecs = Array.from(new Set(Object.values(info).map((i) => i.videoCodec ?? UNKNOWN))).sort(); const seasonFilter = kinds.has("tv") && season.trim() !== "" ? Number(season) : null; @@ -180,6 +278,8 @@ export default function FilterPanel() { if (!fileInfo) return false; if (!extensions.has(file.ext)) return false; if (!kinds.has(fileInfo.kind)) return false; + if (!resolutions.has(fileInfo.resolution ?? UNKNOWN)) return false; + if (!videoCodecs.has(fileInfo.videoCodec ?? UNKNOWN)) return false; if (nameContains.trim() && !displayName(file).toLowerCase().includes(nameContains.trim().toLowerCase())) { return false; } @@ -244,6 +344,40 @@ export default function FilterPanel() { )} + {allResolutions.some((r) => r !== UNKNOWN) && ( +
+ Resolution +
+ {allResolutions.map((res) => ( + + ))} +
+
+ )} + + {allVideoCodecs.some((c) => c !== UNKNOWN) && ( +
+ Video codec +
+ {allVideoCodecs.map((codec) => ( + + ))} +
+
+ )} +
Filename contains - {files.length === 0 &&
Load files to filter them by extension, kind, name, or season.
} + {files.length === 0 && ( +
+ Load files to filter them by extension, kind, name, season, or (when real container probing or a + filename tag finds one) resolution and video codec. +
+ )} {files.length > 0 && (
diff --git a/src/components/ListPanel.tsx b/src/components/ListPanel.tsx index b2b5a7a..c0429ad 100644 --- a/src/components/ListPanel.tsx +++ b/src/components/ListPanel.tsx @@ -29,6 +29,31 @@ 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, same as the Rename panel's Match step: a filename-tag +// value wins whenever a file has both. `resolution`/`duration`/`fileSize` are +// pre-formatted display strings computed on the Rust side. +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; +} + // The naming-scheme-style expression List renders per file, reusing exactly the same // `render_expression` command (and therefore the same `{ }` syntax) the Rename panel's // live preview uses. `{name}`/`{ext}` always work; every other field only fills in once @@ -68,8 +93,15 @@ export default function ListPanel() { async function scan(targets: FileEntry[], base: Record>) { setScanning(true); try { + // One batched round trip for real probing of every target, run alongside the + // per-file filename guessing below rather than after it - same pattern as the + // Rename panel's Match step. + const probePromise = invoke("probe_media_files", { + paths: targets.map((file) => file.path), + }).catch(() => [] as ProbeOutcome[]); + const results = await Promise.all( - targets.map(async (file): Promise]> => { + targets.map(async (file): Promise, ReleaseInfo | null]> => { const releasePromise = invoke("guess_release_info", { filename: displayName(file), }).catch(() => null); @@ -120,19 +152,47 @@ export default function ListPanel() { } : {}; - return [file.path, { ...base[file.path], ...extra, ...releaseBindings }] as const; + return [file.path, { ...base[file.path], ...extra, ...releaseBindings }, release] as const; }) ); + const probes = new Map((await probePromise).map((outcome) => [outcome.path, outcome.probe] as const)); + const next: Record> = {}; let matchedCount = 0; - for (const [path, b] of results) { - next[path] = b; - if (b.n) matchedCount += 1; + let probedCount = 0; + for (const [path, b, release] of results) { + const probe = probes.get(path); + // Real-probed fields: `resolution`/`video codec`/`audio codec` only fill in + // when the filename tag above didn't already provide that field, but + // `width`/`height`/`fps`/`duration`/`file size` have no filename-tag + // equivalent at all, so those always apply whenever probing found them - same + // override rule the Rename panel's Match step uses. + const probeBindings: Record = 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 } + : {}), + } + : {}; + if (probe?.container) probedCount += 1; + next[path] = { ...b, ...probeBindings }; + if (next[path].n) matchedCount += 1; } setBindings(next); setStatus( - `Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (${matchedCount} matched; no network calls).` + `Scanned ${targets.length} file${targets.length === 1 ? "" : "s"} locally (${matchedCount} matched${probedCount > 0 ? `, ${probedCount} probed from file` : ""}; no network calls).` ); } finally { setScanning(false); @@ -225,7 +285,11 @@ export default function ListPanel() { Same {"{ }"} syntax as the naming scheme. {"{name}"}/{"{ext}"} always work;{" "} {"{show name}"}/{"{season number}"}/{"{episode number}"},{" "} {"{movie name}"}/{"{year}"}, and release tags fill in once a file is recognized - - local matching only, no Fetch Data step. + local matching only, no Fetch Data step. Files also get real container probing the same as the Rename + panel's Match step: {"{width}"}/{"{height}"}/{"{fps}"}/ + {"{duration}"}/{"{file size}"} come straight from the file's own header, and a + probed {"{resolution}"}/{"{video codec}"}/{"{audio codec}"} fills in + whenever the filename itself didn't already carry that tag.