File hashing in naming schemes: {crc32}/{md5}/{sha1} via Hash Files
Closes the last remaining gap in porting-roadmap item 1 (full naming-
scheme bindings) - user picked this from a short list of next-feature
options after the recent naming-scheme/Match usability work.
crates/aurora-curator-core/src/sfv: new compute_all_checksums(path),
alongside the existing compute_checksum(path, kind). Streams the file
ONCE, updating CRC32/MD5/SHA-1 hashers concurrently over each chunk,
rather than calling compute_checksum three times (three full reads of
what's often a multi-gigabyte video file). The SFV panel itself keeps
using compute_checksum directly - it only ever needs one algorithm at a
time, picked by the user, so computing the other two nobody asked for
would be wasteful in the other direction. 3 new tests: matches known
CRC32/MD5/SHA-1 test vectors together, agrees with compute_checksum on a
file bigger than the read buffer (catches a hasher only seeing the first
chunk), errors on a missing file.
src-tauri/src/sfv.rs: new NamingHashOutcome struct (plain
#[serde(rename_all = "camelCase")] struct, not a tagged enum - the class
of bug fixed in the nineteenth delivery doesn't apply here) and
compute_naming_hashes command, batching compute_all_checksums the same
blocking-thread way compute_checksums already does, with the same
per-file error isolation (one bad file doesn't abort the batch).
Registered in lib.rs.
RenamePanel.tsx: new runHashFiles() and a "Hash Files" button (new
HashIcon in Icons.tsx) next to Fetch Data. Deliberately NOT wired into
Match automatically like the rest of its local steps - hashing reads a
file's entire contents rather than a small header, so running it
unasked on every Match click for every loaded file would make Match
unpredictably slow. Its own busy state (hashing) disables Match/Fetch
Data/Hash Files against each other while any one is running. New "File
hashing" field-reference group ({crc32}/{md5}/{sha1} as clickable chips,
reusing the field-chip insert-at-cursor mechanism from the earlier
naming-scheme UX delivery) and updated inline help text.
Verified via cargo test --workspace (191 -> 193 in the core crate: 145 ->
148) + cargo check --workspace (clean) + npm run build (clean) + a
10-check Playwright script against npm run dev (mocking
window.__TAURI_INTERNALS__.invoke): confirmed the three field chips are
present, Hash Files calls compute_naming_hashes with every loaded path,
the status line correctly reports hashed-vs-failed counts, a computed
CRC32 reaches the live rename preview, a failed file's row doesn't
fabricate a hash, and clicking Hash Files with nothing loaded is handled
gracefully. Caught one test-mock bug along the way (not an app bug): an
early mock response for a successful hash omitted the `error` field
entirely rather than setting it to `null`, which the app correctly (per
the real Rust command's always-present Option<String> field) treated as
a truthy non-null value and skipped merging - fixed the mock to match
the real wire contract.
Updated README (status paragraph, project layout for sfv.rs's now-four
commands, a new "Hash Files" section placed after "Real media-file
probing", porting roadmap item 1's "still missing" list).
This commit is contained in:
@@ -153,3 +153,11 @@ export function KeyIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HashIcon() {
|
||||
return (
|
||||
<svg {...{ ...base, width: 15, height: 15 }}>
|
||||
<path d="M9 3.5L6.5 20.5M17.5 3.5L15 20.5M4 8.5h16M3 15.5h16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DownIcon,
|
||||
FetchIcon,
|
||||
GearIcon,
|
||||
HashIcon,
|
||||
HistoryIcon,
|
||||
KeyIcon,
|
||||
LoadIcon,
|
||||
@@ -91,6 +92,19 @@ interface ProbeOutcome {
|
||||
probe: MediaProbe;
|
||||
}
|
||||
|
||||
// Mirrors aurora-curator-core's `NamingHashOutcome`. `crc32`/`md5`/`sha1` are `null`
|
||||
// together with a non-null `error` when hashing that particular file failed (moved/
|
||||
// deleted between loading and hashing, a permissions problem, etc) - same per-file
|
||||
// failure shape as `MediaProbe`/`ProbeOutcome` above, so one bad file doesn't block the
|
||||
// rest of the batch.
|
||||
interface NamingHashOutcome {
|
||||
path: string;
|
||||
crc32: string | null;
|
||||
md5: string | null;
|
||||
sha1: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n.toString().padStart(2, "0");
|
||||
}
|
||||
@@ -498,6 +512,12 @@ export default function RenamePanel() {
|
||||
// Match button stays disabled for the whole combined operation rather than just the
|
||||
// first half of it.
|
||||
const [matching, setMatching] = useState(false);
|
||||
// Busy state for "Hash Files" (see `runHashFiles`) - deliberately its own state,
|
||||
// separate from `matching`/`fetching`, since hashing a whole file is much slower
|
||||
// than either of those (a full read of potentially multi-gigabyte video files,
|
||||
// rather than a small filename/header/network round trip) and isn't run
|
||||
// automatically as part of Match - the user opts into it explicitly.
|
||||
const [hashing, setHashing] = useState(false);
|
||||
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
||||
// loading state the first time the History panel is opened).
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
@@ -1368,6 +1388,67 @@ export default function RenamePanel() {
|
||||
}
|
||||
};
|
||||
|
||||
// "Hash Files": computes real CRC32/MD5/SHA-1 checksums for every loaded file and
|
||||
// fills in the {crc32}/{md5}/{sha1} naming-scheme fields - reuses the SFV panel's
|
||||
// own hasher (`aurora_curator_core::sfv::compute_all_checksums`, via the
|
||||
// `compute_naming_hashes` command) rather than a separate implementation. Unlike
|
||||
// Match's other local steps, this isn't wired into Match automatically: hashing
|
||||
// reads a file's *entire* contents (potentially many gigabytes for video), unlike
|
||||
// filename parsing or container-header probing, which only touch a small header -
|
||||
// running it on every Match click for every loaded file, whether or not the naming
|
||||
// scheme even uses a hash field, would make Match unpredictably slow. So this is its
|
||||
// own explicit action, independent of media identification (a hash needs nothing
|
||||
// but a real file on disk - no season/episode guess, no online lookup).
|
||||
const runHashFiles = async () => {
|
||||
const targets = items.filter((i) => !i.isSample);
|
||||
if (targets.length === 0) {
|
||||
setStatus("Load some files first, then Hash Files will compute their CRC32/MD5/SHA-1 checksums.");
|
||||
window.clearTimeout((runHashFiles as any)._t);
|
||||
(runHashFiles as any)._t = window.setTimeout(() => setStatus(null), 3200);
|
||||
return;
|
||||
}
|
||||
|
||||
setHashing(true);
|
||||
try {
|
||||
const results = await invoke<NamingHashOutcome[]>("compute_naming_hashes", {
|
||||
paths: targets.map((item) => item.id),
|
||||
});
|
||||
const byPath = new Map(results.map((r) => [r.path, r] as const));
|
||||
// Count directly from `results`, not inside the `setItems` updater below - same
|
||||
// rule as runMatching/runFetch/confirmRename above (React doesn't run a
|
||||
// functional updater synchronously, so a counter mutated in there wouldn't be
|
||||
// ready yet for the status message right after).
|
||||
const hashed = results.filter((r) => r.error === null).length;
|
||||
const failed = results.length - hashed;
|
||||
|
||||
setItems((prev) =>
|
||||
prev.map((item) => {
|
||||
const result = byPath.get(item.id);
|
||||
if (!result || result.error !== null || !result.crc32 || !result.md5 || !result.sha1) return item;
|
||||
return {
|
||||
...item,
|
||||
bindings: {
|
||||
...item.bindings,
|
||||
crc32: result.crc32,
|
||||
md5: result.md5,
|
||||
sha1: result.sha1,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const parts = [`Hashed ${hashed} of ${targets.length} file${targets.length === 1 ? "" : "s"}`];
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
setStatus(parts.join(", ") + ".");
|
||||
} catch (err) {
|
||||
setStatus(`Hashing failed: ${err}`);
|
||||
} finally {
|
||||
setHashing(false);
|
||||
window.clearTimeout((runHashFiles as any)._t);
|
||||
(runHashFiles as any)._t = window.setTimeout(() => setStatus(null), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSelected = () => {
|
||||
if (!selected) return notImplemented("Excluding items");
|
||||
setItems((prev) => {
|
||||
@@ -1571,7 +1652,7 @@ export default function RenamePanel() {
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => void runMatchAndFetch()}
|
||||
disabled={matching || fetching}
|
||||
disabled={matching || fetching || hashing}
|
||||
title="Scans filenames locally, then automatically fetches real online metadata for anything recognized - no separate Fetch Data click needed."
|
||||
>
|
||||
{matching ? "Matching…" : "Match"}
|
||||
@@ -1596,7 +1677,13 @@ export default function RenamePanel() {
|
||||
icon={<FetchIcon />}
|
||||
label="Fetch Data (Match already runs this automatically - use this to re-fetch)"
|
||||
onClick={() => void runFetch()}
|
||||
disabled={fetching || matching}
|
||||
disabled={fetching || matching || hashing}
|
||||
/>
|
||||
<ListButton
|
||||
icon={<HashIcon />}
|
||||
label="Hash Files (computes real CRC32/MD5/SHA-1 for {crc32}/{md5}/{sha1} - reads the whole file, can be slow)"
|
||||
onClick={() => void runHashFiles()}
|
||||
disabled={hashing || matching || fetching}
|
||||
/>
|
||||
<ListButton icon={<KeyIcon />} label="Fetch Data Settings" onClick={openSettings} />
|
||||
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
||||
@@ -1677,6 +1764,14 @@ export default function RenamePanel() {
|
||||
<FieldChip token="{duration}" />
|
||||
<FieldChip token="{file size}" />
|
||||
</div>
|
||||
<div className="format-fields-group">
|
||||
<span className="format-fields-group-title">
|
||||
File hashing, after <strong>Hash Files</strong> (reads the whole file - can be slow)
|
||||
</span>
|
||||
<FieldChip token="{crc32}" />
|
||||
<FieldChip token="{md5}" />
|
||||
<FieldChip token="{sha1}" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="format-hint">
|
||||
Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text
|
||||
@@ -1687,7 +1782,11 @@ export default function RenamePanel() {
|
||||
<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. Clicking <strong>Match</strong> then automatically runs Fetch Data right after, for anything it
|
||||
this way. <strong>Hash Files</strong> computes real <code>{"{crc32}"}</code>/<code>{"{md5}"}</code>/
|
||||
<code>{"{sha1}"}</code> checksums the same way the SFV panel does — deliberately not automatic like the
|
||||
rest of Match, since hashing reads a file's entire contents rather than just a small header, so it's its
|
||||
own separate, explicit step. Clicking <strong>Match</strong> then automatically runs Fetch Data right
|
||||
after, for anything it
|
||||
recognized — no separate click needed. Fetch Data itself 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{" "}
|
||||
|
||||
Reference in New Issue
Block a user