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:
@@ -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`,
|
||||
|
||||
@@ -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<MediaKind, string> = {
|
||||
@@ -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<Set<string>>(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 [season, setSeason] = useState("");
|
||||
const [status, setStatus] = useState<string | null>(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<ProbeOutcome[]>("probe_media_files", {
|
||||
paths: targets.map((file) => file.path),
|
||||
}).catch(() => [] as ProbeOutcome[]);
|
||||
|
||||
const results = await Promise.all(
|
||||
targets.map(async (file): Promise<readonly [string, FileInfo]> => {
|
||||
const releasePromise = invoke<ReleaseInfo | null>("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<MovieGuess | null>("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<string, FileInfo> = {};
|
||||
const seenExt = new Set<string>();
|
||||
const seenKinds = new Set<MediaKind>();
|
||||
const seenRes = new Set<string>();
|
||||
const seenCodec = new Set<string>();
|
||||
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() {
|
||||
</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">
|
||||
<span className="filter-criteria-label">Filename contains</span>
|
||||
<input
|
||||
@@ -272,7 +406,12 @@ export default function FilterPanel() {
|
||||
)}
|
||||
|
||||
<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 && (
|
||||
<div className="filter-results-header">
|
||||
|
||||
@@ -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<string, Record<string, string>>) {
|
||||
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<ProbeOutcome[]>("probe_media_files", {
|
||||
paths: targets.map((file) => file.path),
|
||||
}).catch(() => [] as ProbeOutcome[]);
|
||||
|
||||
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", {
|
||||
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<string, Record<string, string>> = {};
|
||||
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<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);
|
||||
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 <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>{"{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 className="list-results">
|
||||
|
||||
Reference in New Issue
Block a user