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:
@@ -35,7 +35,9 @@ file's own container header for its real resolution, frame rate, duration, and
|
|||||||
codecs — no ffmpeg/MediaInfo dependency required — filling in `{resolution}`/
|
codecs — no ffmpeg/MediaInfo dependency required — filling in `{resolution}`/
|
||||||
`{video codec}`/`{audio codec}` whenever the filename itself didn't already carry
|
`{video codec}`/`{audio codec}` whenever the filename itself didn't already carry
|
||||||
that tag, plus `{width}`/`{height}`/`{fps}`/`{duration}`/`{file size}`, which have no
|
that tag, plus `{width}`/`{height}`/`{fps}`/`{duration}`/`{file size}`, which have no
|
||||||
filename-tag equivalent at all; see "Real media-file probing" below. The Align
|
filename-tag equivalent at all; see "Real media-file probing" below. A separate **Hash
|
||||||
|
Files** button computes real `{crc32}`/`{md5}`/`{sha1}` checksums on demand (deliberately
|
||||||
|
not automatic, since it reads a file's entire contents — see "Hash Files" below). The Align
|
||||||
Up/Down buttons reorder the (shared) file list — the two
|
Up/Down buttons reorder the (shared) file list — the two
|
||||||
panes always stay positionally paired — and up to 9 naming schemes can be saved as
|
panes always stay positionally paired — and up to 9 naming schemes can be saved as
|
||||||
presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as
|
presets, recalled with a click or a `1`-`9` keypress (persisted locally, same pattern as
|
||||||
@@ -240,14 +242,18 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands
|
|||||||
`browse_episodes` command backing the Episodes panel — series
|
`browse_episodes` command backing the Episodes panel — series
|
||||||
search + one season's episode list, via a `build_tv_client` helper
|
search + one season's episode list, via a `build_tv_client` helper
|
||||||
now shared with `metadata.rs`'s `fetch_metadata`, so both read the
|
now shared with `metadata.rs`'s `fetch_metadata`, so both read the
|
||||||
same stored TV-source setting and credential), `sfv.rs` (three
|
same stored TV-source setting and credential), `sfv.rs` (four
|
||||||
commands backing the SFV panel — `compute_checksums` over a batch
|
commands backing the SFV panel and the Rename panel's Hash Files
|
||||||
of loaded files with per-file error isolation, `save_checksum_file`
|
button — `compute_checksums` over a batch of loaded files with
|
||||||
via a native "Save File" dialog, and `verify_checksum_file`, which
|
per-file error isolation, `save_checksum_file` via a native "Save
|
||||||
reads and parses a picked `.sfv`/`.md5`/`.sha1` file and resolves
|
File" dialog, `verify_checksum_file`, which reads and parses a
|
||||||
each entry's bare filename relative to that file's own parent
|
picked `.sfv`/`.md5`/`.sha1` file and resolves each entry's bare
|
||||||
directory — all wrapping `aurora-curator-core::sfv`, which does the
|
filename relative to that file's own parent directory, and
|
||||||
actual algorithm/format work),
|
`compute_naming_hashes` — all four wrapping
|
||||||
|
`aurora-curator-core::sfv`, which does the actual algorithm/format
|
||||||
|
work; `compute_naming_hashes` calls the crate's
|
||||||
|
`compute_all_checksums` rather than `compute_checksum` three times,
|
||||||
|
streaming each file once instead of three),
|
||||||
`history.rs` (rename history/undo
|
`history.rs` (rename history/undo
|
||||||
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
log), `presets.rs` (up to 9 saved naming schemes) — all three
|
||||||
stored as JSON in the OS app-config directory — `list.rs` (a
|
stored as JSON in the OS app-config directory — `list.rs` (a
|
||||||
@@ -601,6 +607,24 @@ 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
|
filename-tag-or-probed value) so a batch can be narrowed by either, not just by
|
||||||
extension/kind/name/season.
|
extension/kind/name/season.
|
||||||
|
|
||||||
|
## Hash Files
|
||||||
|
|
||||||
|
A naming-scheme binding source, same idea as real media-file probing above, but for
|
||||||
|
`{crc32}`/`{md5}`/`{sha1}` — reuses the SFV panel's own hasher
|
||||||
|
(`aurora_curator_core::sfv`) rather than a separate implementation. Unlike Match's other
|
||||||
|
local steps (filename parsing, container-header probing), this is deliberately **not**
|
||||||
|
wired into Match automatically: a checksum needs the file's *entire* contents streamed
|
||||||
|
through, not just a small header or filename, so hashing a batch of large video files
|
||||||
|
can take real time — running it unasked on every Match click would make Match
|
||||||
|
unpredictably slow for schemes that don't even use a hash field. Instead, the Rename
|
||||||
|
panel's New Names list has its own **Hash Files** button next to Fetch Data; clicking it
|
||||||
|
computes all three checksums for every loaded file in one pass per file (not three
|
||||||
|
separate passes) and fills in `{crc32}`/`{md5}`/`{sha1}` for whichever files succeeded,
|
||||||
|
reporting how many hashed vs. failed (a file that's moved or deleted between loading and
|
||||||
|
hashing, a permissions problem, etc. — kept per-file, so one bad file doesn't block the
|
||||||
|
rest of the batch). The naming-scheme editor's field-reference list has a "File hashing"
|
||||||
|
group with all three as clickable buttons.
|
||||||
|
|
||||||
## Porting roadmap (not yet done)
|
## Porting roadmap (not yet done)
|
||||||
|
|
||||||
Roughly in priority order — each references the original FileBot source file it's
|
Roughly in priority order — each references the original FileBot source file it's
|
||||||
@@ -619,10 +643,13 @@ ported from, for anyone picking this up:
|
|||||||
for the two container families it supports (MP4/M4V/MOV and MKV/WebM). Probed
|
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
|
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
|
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
|
Resolution/Video codec filter chips from it). File hashing is done too —
|
||||||
containers (.avi, .wmv, ...), audio-only metadata (bitrate, sample rate, channel
|
`{crc32}`/`{md5}`/`{sha1}` reuse the SFV panel's own hasher (all three computed in a
|
||||||
layout), and file hashing (`{crc32}` reuses the SFV panel's own hasher today but
|
single pass over the file, rather than reading it three times), wired to the Rename
|
||||||
isn't wired into naming-scheme bindings yet).
|
panel's own "Hash Files" button rather than Match, since a full-file read is much
|
||||||
|
slower than everything else Match does and shouldn't run unasked for every batch
|
||||||
|
(see "Hash Files" below). Still missing: probing for other containers (.avi, .wmv,
|
||||||
|
...) and audio-only metadata (bitrate, sample rate, channel layout).
|
||||||
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`,
|
||||||
|
|||||||
@@ -98,6 +98,48 @@ pub fn compute_checksum(path: &Path, kind: ChecksumKind) -> io::Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All three checksums for one file, computed together - see [`compute_all_checksums`].
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct AllChecksums {
|
||||||
|
pub crc32: String,
|
||||||
|
pub md5: String,
|
||||||
|
pub sha1: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same job as calling [`compute_checksum`] three times (once per [`ChecksumKind`]),
|
||||||
|
/// but streams the file exactly once instead of three times - used for the naming-scheme
|
||||||
|
/// `{crc32}`/`{md5}`/`{sha1}` fields (see `src-tauri/src/sfv.rs`'s `compute_naming_hashes`),
|
||||||
|
/// where a scheme referencing more than one hash field would otherwise hash the same
|
||||||
|
/// (often multi-gigabyte video) file redundantly. The SFV panel itself still calls
|
||||||
|
/// [`compute_checksum`] directly - it only ever needs one algorithm at a time, picked by
|
||||||
|
/// the user, so tripling its I/O for the other two nobody asked for would be wasteful in
|
||||||
|
/// the other direction.
|
||||||
|
pub fn compute_all_checksums(path: &Path) -> io::Result<AllChecksums> {
|
||||||
|
let mut file = File::open(path)?;
|
||||||
|
let mut buf = [0u8; READ_BUFFER_SIZE];
|
||||||
|
|
||||||
|
let mut crc32 = crc32fast::Hasher::new();
|
||||||
|
let mut md5 = Md5::new();
|
||||||
|
let mut sha1 = Sha1::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let n = file.read(&mut buf)?;
|
||||||
|
if n == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let chunk = &buf[..n];
|
||||||
|
crc32.update(chunk);
|
||||||
|
md5.update(chunk);
|
||||||
|
sha1.update(chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(AllChecksums {
|
||||||
|
crc32: format!("{:08X}", crc32.finalize()),
|
||||||
|
md5: format!("{:x}", md5.finalize()),
|
||||||
|
sha1: format!("{:x}", sha1.finalize()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// One entry parsed out of an existing checksum file: the file name it references
|
/// One entry parsed out of an existing checksum file: the file name it references
|
||||||
/// (not yet resolved to a real path - checksum files reference files by name only,
|
/// (not yet resolved to a real path - checksum files reference files by name only,
|
||||||
/// always relative to wherever the checksum file itself lives) and the checksum text
|
/// always relative to wherever the checksum file itself lives) and the checksum text
|
||||||
@@ -293,6 +335,44 @@ mod tests {
|
|||||||
assert!(compute_checksum(&missing, ChecksumKind::Crc32).is_err());
|
assert!(compute_checksum(&missing, ChecksumKind::Crc32).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_all_checksums_matches_the_known_test_vectors_all_at_once() {
|
||||||
|
// Same "123456789" CRC32 vector as above, computed alongside MD5/SHA-1 of the
|
||||||
|
// same bytes in one pass - pins that streaming all three hashers together over
|
||||||
|
// the same chunks produces the identical result as hashing separately.
|
||||||
|
let dir = temp_dir();
|
||||||
|
let path = dir.join("check.txt");
|
||||||
|
fs::write(&path, b"123456789").unwrap();
|
||||||
|
let all = compute_all_checksums(&path).unwrap();
|
||||||
|
assert_eq!(all.crc32, "CBF43926");
|
||||||
|
assert_eq!(all.md5, "25f9e794323b453885f5181f1b624d0b"); // md5("123456789")
|
||||||
|
assert_eq!(all.sha1, "f7c3bc1d808e04732adf679965ccc34ca7ae3441"); // sha1("123456789")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_all_checksums_agrees_with_compute_checksum_on_a_multi_chunk_file() {
|
||||||
|
// Bigger than READ_BUFFER_SIZE (64 KiB) so both the individual and combined
|
||||||
|
// paths each have to loop over more than one read() - catches a bug where one
|
||||||
|
// of the three hashers only got the first chunk (e.g. an accidental `break`
|
||||||
|
// inside one match arm instead of the shared loop).
|
||||||
|
let dir = temp_dir();
|
||||||
|
let path = dir.join("big.bin");
|
||||||
|
let bytes: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
|
||||||
|
fs::write(&path, &bytes).unwrap();
|
||||||
|
|
||||||
|
let all = compute_all_checksums(&path).unwrap();
|
||||||
|
assert_eq!(all.crc32, compute_checksum(&path, ChecksumKind::Crc32).unwrap());
|
||||||
|
assert_eq!(all.md5, compute_checksum(&path, ChecksumKind::Md5).unwrap());
|
||||||
|
assert_eq!(all.sha1, compute_checksum(&path, ChecksumKind::Sha1).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compute_all_checksums_errors_when_the_file_does_not_exist() {
|
||||||
|
let dir = temp_dir();
|
||||||
|
let missing = dir.join("nope.txt");
|
||||||
|
assert!(compute_all_checksums(&missing).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_a_typical_sfv_file_skipping_comments_and_blank_lines() {
|
fn parses_a_typical_sfv_file_skipping_comments_and_blank_lines() {
|
||||||
let content = "; Generated by Aurora Curator\n\nShow.S01E01.mkv CBF43926\nShow.S01E02.mkv 12345678\n";
|
let content = "; Generated by Aurora Curator\n\nShow.S01E01.mkv CBF43926\nShow.S01E02.mkv 12345678\n";
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ pub fn run() {
|
|||||||
sfv::compute_checksums,
|
sfv::compute_checksums,
|
||||||
sfv::save_checksum_file,
|
sfv::save_checksum_file,
|
||||||
sfv::verify_checksum_file,
|
sfv::verify_checksum_file,
|
||||||
|
sfv::compute_naming_hashes,
|
||||||
list::export_text_list,
|
list::export_text_list,
|
||||||
probe::probe_media_files,
|
probe::probe_media_files,
|
||||||
history::list_rename_history,
|
history::list_rename_history,
|
||||||
|
|||||||
@@ -53,6 +53,51 @@ pub async fn compute_checksums(paths: Vec<String>, kind: ChecksumKind) -> Vec<Ch
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All three checksums for one file, for the naming-scheme `{crc32}`/`{md5}`/`{sha1}`
|
||||||
|
/// fields (see `RenamePanel.tsx`'s `runHashFiles`) - unlike `ChecksumOutcome` above,
|
||||||
|
/// which is one algorithm at a time for the SFV panel, this is all three at once so a
|
||||||
|
/// naming scheme referencing more than one of them doesn't hash the same file three
|
||||||
|
/// times over. `error` is set (and all three checksums left `None`) if hashing this
|
||||||
|
/// particular file failed - kept per-file, same as `ChecksumOutcome`, so one bad file
|
||||||
|
/// doesn't abort the whole batch.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct NamingHashOutcome {
|
||||||
|
pub path: String,
|
||||||
|
pub crc32: Option<String>,
|
||||||
|
pub md5: Option<String>,
|
||||||
|
pub sha1: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes CRC32 + MD5 + SHA-1 for every path in `paths` in one pass per file (see
|
||||||
|
/// `aurora_curator_core::sfv::compute_all_checksums`). Same blocking-thread treatment
|
||||||
|
/// as `compute_checksums` - real file I/O, potentially large video files - and
|
||||||
|
/// deliberately a separate, explicit action from "Match" rather than something that
|
||||||
|
/// runs automatically: hashing a whole file is much slower than Match's other local
|
||||||
|
/// steps (filename parsing, container-header probing), which only read a small header,
|
||||||
|
/// not the entire file.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn compute_naming_hashes(paths: Vec<String>) -> Vec<NamingHashOutcome> {
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|path| match sfv::compute_all_checksums(Path::new(&path)) {
|
||||||
|
Ok(all) => NamingHashOutcome {
|
||||||
|
path,
|
||||||
|
crc32: Some(all.crc32),
|
||||||
|
md5: Some(all.md5),
|
||||||
|
sha1: Some(all.sha1),
|
||||||
|
error: None,
|
||||||
|
},
|
||||||
|
Err(err) => NamingHashOutcome { path, crc32: None, md5: None, sha1: None, error: Some(err.to_string()) },
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
/// One row the frontend already has (a computed checksum for a loaded file) - the
|
/// One row the frontend already has (a computed checksum for a loaded file) - the
|
||||||
/// input shape `save_checksum_file` builds a sidecar file's text content from.
|
/// input shape `save_checksum_file` builds a sidecar file's text content from.
|
||||||
#[derive(Debug, Clone, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Deserialize)]
|
||||||
|
|||||||
@@ -153,3 +153,11 @@ export function KeyIcon() {
|
|||||||
</svg>
|
</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,
|
DownIcon,
|
||||||
FetchIcon,
|
FetchIcon,
|
||||||
GearIcon,
|
GearIcon,
|
||||||
|
HashIcon,
|
||||||
HistoryIcon,
|
HistoryIcon,
|
||||||
KeyIcon,
|
KeyIcon,
|
||||||
LoadIcon,
|
LoadIcon,
|
||||||
@@ -91,6 +92,19 @@ interface ProbeOutcome {
|
|||||||
probe: MediaProbe;
|
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 {
|
function pad2(n: number): string {
|
||||||
return n.toString().padStart(2, "0");
|
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
|
// Match button stays disabled for the whole combined operation rather than just the
|
||||||
// first half of it.
|
// first half of it.
|
||||||
const [matching, setMatching] = useState(false);
|
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
|
// Rename history / undo. `null` batches = not loaded yet (also used to show a
|
||||||
// loading state the first time the History panel is opened).
|
// loading state the first time the History panel is opened).
|
||||||
const [historyOpen, setHistoryOpen] = useState(false);
|
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 = () => {
|
const removeSelected = () => {
|
||||||
if (!selected) return notImplemented("Excluding items");
|
if (!selected) return notImplemented("Excluding items");
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
@@ -1571,7 +1652,7 @@ export default function RenamePanel() {
|
|||||||
<button
|
<button
|
||||||
className="action-button"
|
className="action-button"
|
||||||
onClick={() => void runMatchAndFetch()}
|
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."
|
title="Scans filenames locally, then automatically fetches real online metadata for anything recognized - no separate Fetch Data click needed."
|
||||||
>
|
>
|
||||||
{matching ? "Matching…" : "Match"}
|
{matching ? "Matching…" : "Match"}
|
||||||
@@ -1596,7 +1677,13 @@ export default function RenamePanel() {
|
|||||||
icon={<FetchIcon />}
|
icon={<FetchIcon />}
|
||||||
label="Fetch Data (Match already runs this automatically - use this to re-fetch)"
|
label="Fetch Data (Match already runs this automatically - use this to re-fetch)"
|
||||||
onClick={() => void runFetch()}
|
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={<KeyIcon />} label="Fetch Data Settings" onClick={openSettings} />
|
||||||
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
<ListButton icon={<GearIcon />} label="Naming Scheme" onClick={() => setEditingFormat((v) => !v)} />
|
||||||
@@ -1677,6 +1764,14 @@ export default function RenamePanel() {
|
|||||||
<FieldChip token="{duration}" />
|
<FieldChip token="{duration}" />
|
||||||
<FieldChip token="{file size}" />
|
<FieldChip token="{file size}" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<p className="format-hint">
|
<p className="format-hint">
|
||||||
Match scans filenames locally for season/episode or movie title/year patterns, plus release-tag text
|
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
|
<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>/
|
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
|
<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
|
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
|
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{" "}
|
come from TMDB, TVmaze, or OMDb, and movies from TMDB or OMDb — pick each independently in{" "}
|
||||||
|
|||||||
Reference in New Issue
Block a user