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).
This commit is contained in:
Renamr Scaffold
2026-08-30 03:48:22 +00:00
parent 23c9ca609f
commit e219e8f2a8
3 changed files with 283 additions and 58 deletions
+66 -44
View File
@@ -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 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 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 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()`, scripting layer also gained a batch of FileBot-style string helpers — `.pad()`,
`.sort_name()`/`.sort_initial()`, `.upper_initial()`/`.lower_initial()`, `.acronym()`, `.sort_name()`/`.sort_initial()`, `.upper_initial()`/`.lower_initial()`, `.acronym()`,
`.validate_file_name()`, `.space()`/`.space_with()`, `.colon()`/`.colon_with()` — callable `.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 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 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, the matching paths out, or just get a sense of what's actually in a folder. It scans every
it adds no new Rust/Tauri code at all: it scans every loaded file with the exact same loaded file with the exact same local, filename-only matchers "Match" uses
local, filename-only matchers "Match" uses (`guess_episode`/`guess_movie`/ (`guess_episode`/`guess_movie`/`guess_release_info`) and turns the results into filter
`guess_release_info`) and turns the results into filter criteria instead of a rename criteria instead of a rename preview — no network calls, so scanning even a large batch is
preview — no network calls, so scanning even a large batch is effectively instant. 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 **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 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 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 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 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 that season. Each result row's subtitle also shows a resolved resolution when one exists
**Copy File Paths** writes every currently-matching file's full path, one per line, to the (e.g. `Unmatched · 480p` for a file Match couldn't identify as TV or a movie but still got
clipboard. 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 ## 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 everything else (matching, and rendering each file's line) reuses commands that already
existed. existed.
**Load Files…** loads a batch and scans it locally, the same way Filter does. The **Load Files…** loads a batch and scans it locally, the same way Filter does — including
**Format** field holds the expression rendered against each file — the exact same `{ }` the same batched real container-header probe (see "Real media-file probing" below), so
syntax the naming scheme uses, run through the same `render_expression` command the List's expressions get real probed values too, not just filename-tag ones. The **Format**
Rename panel's live preview calls. `{name}`/`{ext}` always work (they're the file's own field holds the expression rendered against each file — the exact same `{ }` syntax the
name/extension); `{show name}`/`{season number}`/`{episode number}`, `{movie name}`/ naming scheme uses, run through the same `render_expression` command the Rename panel's
`{year}`, and the release-tag fields (`{resolution}`/`{source}`/`{video codec}`/ live preview calls. `{name}`/`{ext}` always work (they're the file's own name/extension);
`{audio codec}`/`{release group}`) fill in once the local scan recognizes a file — there's `{show name}`/`{season number}`/`{episode number}`, `{movie name}`/`{year}`, and the
no Fetch Data step here, List stays local-only like Filter. A file whose rendered line release-tag fields (`{resolution}`/`{source}`/`{video codec}`/`{audio codec}`/`{release
comes out empty (e.g. an expression that only references a field this particular file group}`) fill in once the local scan recognizes a file; `{width}`/`{height}`/`{fps}`/
never matched) shows an "(empty)" placeholder rather than blocking the rest of the list, `{duration}`/`{file size}` come straight from real probing (no filename-tag equivalent
and the results header tracks "N of M lines rendered" live as the expression changes. exists), and a probed `{resolution}`/`{video codec}`/`{audio codec}` fills in whenever the
**Copy List** copies every line, in file order, newline-joined, to the clipboard; **Export filename didn't already carry that tag — there's no Fetch Data step here, List stays
to File…** opens a native Save dialog (defaulted to `list.txt`) and writes the same text local-only like Filter. A file whose rendered line comes out empty (e.g. an expression
to disk. 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 ## Real media-file probing
Unlike every panel above, this isn't a new sidebar screen — it's a naming-scheme 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) binding source wired into the Rename panel's "Match" step, the Filter panel's scan, and
the gap the porting roadmap has called out since the beginning: FileBot's 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 `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 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 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 matching, never a hard requirement, so an unsupported or corrupt file can't break
Match. Match.
Clicking **Match** now also probes every loaded file's real container header Clicking **Match** (Rename), or loading a batch into **Filter** or **List**, now also
(`probe_media_files`, one batched Tauri round trip off the main thread) alongside the probes every loaded file's real container header (`probe_media_files`, one batched Tauri
existing filename-based guessing. The result feeds the same bindings the release-tag round trip off the main thread) alongside the existing filename-based guessing — all
parser already populates — `{resolution}`/`{vf}`, `{video codec}`/`{vc}`, three panels call the exact same command the exact same way. The result feeds the same
`{audio codec}`/`{ac}` — but only when the filename itself didn't already carry that bindings the release-tag parser already populates — `{resolution}`/`{vf}`, `{video
tag; an explicit release-group tag always wins over a probed value, since a release codec}`/`{vc}`, `{audio codec}`/`{ac}` — but only when the filename itself didn't already
group's own tag is usually deliberate. Five bindings have no filename-tag equivalent carry that tag; an explicit release-group tag always wins over a probed value, since a
at all, so they're only ever available this way: `{width}`, `{height}`, `{fps}`, release group's own tag is usually deliberate. Five bindings have no filename-tag
`{duration}` (`h:mm:ss`, or `m:ss` under an hour), and `{file size}` (human-readable, equivalent at all, so they're only ever available this way: `{width}`, `{height}`,
e.g. `1.4 GB`) — all formatted once on the Rust side so the UI never reimplements that `{fps}`, `{duration}` (`h:mm:ss`, or `m:ss` under an hour), and `{file size}`
logic. The Match status line reports how many loaded files were actually probed (human-readable, e.g. `1.4 GB`) — all formatted once on the Rust side so the UI never
(`"N probed from file"`), and the naming-scheme editor's field-reference list has a reimplements that logic. Rename's Match status line, List's scan status, and Filter's
new "Real file probing" group listing all five. 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) ## 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 actually reading the file — real resolution/video codec/audio codec fill in
whenever the filename didn't already carry that tag, plus `{width}`/`{height}`/ whenever the filename didn't already carry that tag, plus `{width}`/`{height}`/
`{fps}`/`{duration}`/`{file size}`, which have no filename-tag equivalent at all — `{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 for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Probed
missing: probing for other containers (.avi, .wmv, ...), audio-only metadata bindings are now wired into all three panels that do local matching — Rename's
(bitrate, sample rate, channel layout), file hashing (`{crc32}` reuses the SFV Match step, and the Filter and List panels' own scans (Filter also gained
panel's own hasher today but isn't wired into naming-scheme bindings yet), and Resolution/Video codec filter chips from it). Still missing: probing for other
wiring probed bindings into the List/Filter panels (currently Rename-panel-only). 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` 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()`.
Ported so far (`crates/aurora-curator-core/src/format/functions.rs`): `any`, `all_of`, Ported so far (`crates/aurora-curator-core/src/format/functions.rs`): `any`, `all_of`,
+146 -7
View File
@@ -29,6 +29,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/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"; type MediaKind = "tv" | "movie" | "unmatched";
interface FileInfo { interface FileInfo {
@@ -39,6 +65,12 @@ interface FileInfo {
movieName?: string; movieName?: string;
year?: number | null; year?: number | null;
release: ReleaseInfo | 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<MediaKind, string> = { const KIND_LABELS: Record<MediaKind, string> = {
@@ -57,14 +89,15 @@ function displayName(file: FileEntry): string {
function subtitleFor(info: FileInfo | undefined): string { function subtitleFor(info: FileInfo | undefined): string {
if (!info) return ""; if (!info) return "";
const tag = info.resolution ? ` · ${info.resolution}` : "";
if (info.kind === "tv") { if (info.kind === "tv") {
const se = `S${String(info.season ?? 0).padStart(2, "0")}E${String(info.episode ?? 0).padStart(2, "0")}`; 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") { 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() { export default function FilterPanel() {
@@ -73,6 +106,8 @@ export default function FilterPanel() {
const [scanning, setScanning] = useState(false); const [scanning, setScanning] = useState(false);
const [extensions, setExtensions] = useState<Set<string>>(new Set()); const [extensions, setExtensions] = useState<Set<string>>(new Set());
const [kinds, setKinds] = useState<Set<MediaKind>>(new Set()); const [kinds, setKinds] = useState<Set<MediaKind>>(new Set());
const [resolutions, setResolutions] = useState<Set<string>>(new Set());
const [videoCodecs, setVideoCodecs] = useState<Set<string>>(new Set());
const [nameContains, setNameContains] = useState(""); const [nameContains, setNameContains] = useState("");
const [season, setSeason] = useState(""); const [season, setSeason] = useState("");
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
@@ -91,6 +126,13 @@ export default function FilterPanel() {
async function scan(targets: FileEntry[]) { async function scan(targets: FileEntry[]) {
setScanning(true); setScanning(true);
try { 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<ProbeOutcome[]>("probe_media_files", {
paths: targets.map((file) => file.path),
}).catch(() => [] as ProbeOutcome[]);
const results = await Promise.all( const results = await Promise.all(
targets.map(async (file): Promise<readonly [string, FileInfo]> => { targets.map(async (file): Promise<readonly [string, FileInfo]> => {
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", { const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
@@ -107,6 +149,9 @@ export default function FilterPanel() {
season: episode.season, season: episode.season,
episode: episode.episode, episode: episode.episode,
release: null, release: null,
resolution: null,
videoCodec: null,
probed: false,
}; };
} }
} catch { } catch {
@@ -116,31 +161,61 @@ export default function FilterPanel() {
try { try {
const movie = await invoke<MovieGuess | null>("guess_movie", { filename: displayName(file) }); const movie = await invoke<MovieGuess | null>("guess_movie", { filename: displayName(file) });
if (movie) { 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 { } catch {
// Neither guess worked. // 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; fileInfo.release = await releasePromise;
return [file.path, fileInfo] as const; return [file.path, fileInfo] as const;
}) })
); );
const probes = new Map((await probePromise).map((outcome) => [outcome.path, outcome.probe] as const));
const nextInfo: Record<string, FileInfo> = {}; const nextInfo: Record<string, FileInfo> = {};
const seenExt = new Set<string>(); const seenExt = new Set<string>();
const seenKinds = new Set<MediaKind>(); const seenKinds = new Set<MediaKind>();
const seenRes = new Set<string>();
const seenCodec = new Set<string>();
let probedCount = 0;
for (const [path, fileInfo] of results) { 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; nextInfo[path] = fileInfo;
seenKinds.add(fileInfo.kind); seenKinds.add(fileInfo.kind);
seenRes.add(fileInfo.resolution ?? UNKNOWN);
seenCodec.add(fileInfo.videoCodec ?? UNKNOWN);
} }
for (const file of targets) seenExt.add(file.ext); for (const file of targets) seenExt.add(file.ext);
setInfo(nextInfo); setInfo(nextInfo);
setExtensions(seenExt); setExtensions(seenExt);
setKinds(seenKinds); 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 { } finally {
setScanning(false); 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 allExtensions = Array.from(new Set(files.map((f) => f.ext))).sort();
const allKinds = (["tv", "movie", "unmatched"] as MediaKind[]).filter((k) => const allKinds = (["tv", "movie", "unmatched"] as MediaKind[]).filter((k) =>
Object.values(info).some((i) => i.kind === 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; const seasonFilter = kinds.has("tv") && season.trim() !== "" ? Number(season) : null;
@@ -180,6 +278,8 @@ export default function FilterPanel() {
if (!fileInfo) return false; if (!fileInfo) return false;
if (!extensions.has(file.ext)) return false; if (!extensions.has(file.ext)) return false;
if (!kinds.has(fileInfo.kind)) 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())) { if (nameContains.trim() && !displayName(file).toLowerCase().includes(nameContains.trim().toLowerCase())) {
return false; return false;
} }
@@ -244,6 +344,40 @@ export default function FilterPanel() {
</div> </div>
)} )}
{allResolutions.some((r) => r !== UNKNOWN) && (
<div className="filter-criteria-row">
<span className="filter-criteria-label">Resolution</span>
<div className="filter-chip-row" role="group" aria-label="Filter by resolution">
{allResolutions.map((res) => (
<button
key={res}
className={`filter-chip${resolutions.has(res) ? " selected" : ""}`}
onClick={() => toggleResolution(res)}
>
{res === UNKNOWN ? "Unknown" : res}
</button>
))}
</div>
</div>
)}
{allVideoCodecs.some((c) => c !== UNKNOWN) && (
<div className="filter-criteria-row">
<span className="filter-criteria-label">Video codec</span>
<div className="filter-chip-row" role="group" aria-label="Filter by video codec">
{allVideoCodecs.map((codec) => (
<button
key={codec}
className={`filter-chip${videoCodecs.has(codec) ? " selected" : ""}`}
onClick={() => toggleVideoCodec(codec)}
>
{codec === UNKNOWN ? "Unknown" : codec}
</button>
))}
</div>
</div>
)}
<div className="filter-criteria-row"> <div className="filter-criteria-row">
<span className="filter-criteria-label">Filename contains</span> <span className="filter-criteria-label">Filename contains</span>
<input <input
@@ -272,7 +406,12 @@ export default function FilterPanel() {
)} )}
<div className="filter-results"> <div className="filter-results">
{files.length === 0 && <div className="filter-empty-note">Load files to filter them by extension, kind, name, or season.</div>} {files.length === 0 && (
<div className="filter-empty-note">
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.
</div>
)}
{files.length > 0 && ( {files.length > 0 && (
<div className="filter-results-header"> <div className="filter-results-header">
+71 -7
View File
@@ -29,6 +29,31 @@ 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, 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 // 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 // `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 // 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<string, Record<string, string>>) { async function scan(targets: FileEntry[], base: Record<string, Record<string, string>>) {
setScanning(true); setScanning(true);
try { 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<ProbeOutcome[]>("probe_media_files", {
paths: targets.map((file) => file.path),
}).catch(() => [] as ProbeOutcome[]);
const results = await Promise.all( const results = await Promise.all(
targets.map(async (file): Promise<readonly [string, Record<string, string>]> => { targets.map(async (file): Promise<readonly [string, Record<string, string>, ReleaseInfo | null]> => {
const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", { const releasePromise = invoke<ReleaseInfo | null>("guess_release_info", {
filename: displayName(file), filename: displayName(file),
}).catch(() => null); }).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<string, Record<string, string>> = {}; const next: Record<string, Record<string, string>> = {};
let matchedCount = 0; let matchedCount = 0;
for (const [path, b] of results) { let probedCount = 0;
next[path] = b; for (const [path, b, release] of results) {
if (b.n) matchedCount += 1; 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<string, string> = 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); setBindings(next);
setStatus( 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 { } finally {
setScanning(false); setScanning(false);
@@ -225,7 +285,11 @@ export default function ListPanel() {
Same <code>{"{ }"}</code> syntax as the naming scheme. <code>{"{name}"}</code>/<code>{"{ext}"}</code> always work;{" "} Same <code>{"{ }"}</code> syntax as the naming scheme. <code>{"{name}"}</code>/<code>{"{ext}"}</code> always work;{" "}
<code>{"{show name}"}</code>/<code>{"{season number}"}</code>/<code>{"{episode number}"}</code>,{" "} <code>{"{show name}"}</code>/<code>{"{season number}"}</code>/<code>{"{episode number}"}</code>,{" "}
<code>{"{movie name}"}</code>/<code>{"{year}"}</code>, and release tags fill in once a file is recognized - <code>{"{movie name}"}</code>/<code>{"{year}"}</code>, 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: <code>{"{width}"}</code>/<code>{"{height}"}</code>/<code>{"{fps}"}</code>/
<code>{"{duration}"}</code>/<code>{"{file size}"}</code> come straight from the file's own header, and a
probed <code>{"{resolution}"}</code>/<code>{"{video codec}"}</code>/<code>{"{audio codec}"}</code> fills in
whenever the filename itself didn't already carry that tag.
</div> </div>
<div className="list-results"> <div className="list-results">