From 316dbfc1ff374676fe544603479e801521d3e3d0 Mon Sep 17 00:00:00 2001 From: Renamr Scaffold Date: Sat, 29 Aug 2026 15:59:05 +0000 Subject: [PATCH] Add SFV panel: compute/verify CRC32/MD5/SHA1 checksums, .sfv/.md5/.sha1 sidecar files Fully local feature - no network access, no credentials. New aurora-curator-core::sfv module streams file hashing in 64KB chunks and handles both the traditional .sfv format (CRC32, "name checksum" layout) and the GNU coreutils md5sum/sha1sum sidecar convention (.md5/.sha1, "checksum name" layout), dispatched on a shared ChecksumKind enum. New src-tauri/src/sfv.rs wraps it in three Tauri commands - compute_checksums (per-file error isolation), save_checksum_file (native Save dialog), and verify_checksum_file (resolves each entry against the checksum file's own parent directory, reports OK/Mismatch/Missing per entry). New SfvPanel.tsx/.css wires it all into the sidebar's SFV screen - load files, pick a checksum type, Compute, Save, or Verify against an existing checksum file. 14 new core-crate tests + 1 new src-tauri test, all passing (workspace: 160/160). Verified end-to-end with a 22-check Playwright script against npm run dev. --- Cargo.lock | 24 ++ README.md | 68 +++- crates/aurora-curator-core/Cargo.toml | 6 + crates/aurora-curator-core/src/lib.rs | 1 + crates/aurora-curator-core/src/sfv/mod.rs | 406 ++++++++++++++++++++++ src-tauri/src/lib.rs | 4 + src-tauri/src/sfv.rs | 133 +++++++ src/App.tsx | 3 + src/components/SfvPanel.css | 105 ++++++ src/components/SfvPanel.tsx | 197 +++++++++++ src/main.tsx | 1 + 11 files changed, 940 insertions(+), 8 deletions(-) create mode 100644 crates/aurora-curator-core/src/sfv/mod.rs create mode 100644 src-tauri/src/sfv.rs create mode 100644 src/components/SfvPanel.css create mode 100644 src/components/SfvPanel.tsx diff --git a/Cargo.lock b/Cargo.lock index 4cf0612..fa5a3c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,12 +119,15 @@ dependencies = [ name = "aurora-curator-core" version = "0.1.0" dependencies = [ + "crc32fast", + "md-5", "mockito", "regex", "reqwest 0.12.28", "rhai", "serde", "serde_json", + "sha1", "thiserror 1.0.69", "tokio", ] @@ -1933,6 +1936,16 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.3" @@ -3266,6 +3279,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/README.md b/README.md index cd2e06d..06585cf 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,10 @@ result, and the subtitle is saved right next to the video ready for any player t up automatically; see "Subtitles" below. A new Episodes panel searches a series by name and browses a full season's episode list — episode number, title, air date — using whichever TV source (TMDB/TVmaze/OMDb) is already configured in Fetch Data Settings, no -separate credentials needed; see "Episodes" below. See "Porting roadmap" below. +separate credentials needed; see "Episodes" below. A new SFV panel computes and verifies +CRC32/MD5/SHA1 checksums for a loaded set of files, and reads and writes the classic +`.sfv`/`.md5`/`.sha1` sidecar-file formats — entirely local, no network access or +credentials of any kind; see "SFV" below. See "Porting roadmap" below. The naming scheme is fully user-editable from the app (the "Naming Scheme" button next to the New Names list) — the default is `{show name} - {season number}x{episode @@ -161,7 +164,16 @@ crates/aurora-curator-core/ Pure Rust core: naming-expression engine (ported), (the same season-resolving call `AnyTvClient` already made available to "Fetch Data" and the CLI) from the Tauri side, so `web/tmdb.rs`/ `tvmaze.rs`/`omdb.rs`'s existing season-episode-list methods are - exactly what it browses. The on-disk + exactly what it browses. `sfv/` computes CRC32/MD5/SHA1 checksums + (streamed in 64KB chunks so large video files aren't loaded whole + into memory) and parses/builds both sidecar-file conventions the + SFV panel supports — the traditional `.sfv` layout (CRC32 only, + ` ` per line) and the GNU coreutils + `md5sum`/`sha1sum` layout (` `) used for `.md5`/ + `.sha1` — dispatched on a shared `ChecksumKind` enum, plus + entry-verification against real files on disk (`Ok`/`Mismatch`/ + `Missing` per entry). No network access, no credentials — entirely + local, unlike every other panel added this session. The on-disk rename engine (`rename/` — Move/Copy/Symlink actions, Skip/Auto-number/Override conflict policies, can also organize into (auto-created) subfolders when @@ -192,7 +204,14 @@ src-tauri/ Tauri app shell: exposes aurora-curator-core as commands `browse_episodes` command backing the Episodes panel — series search + one season's episode list, via a `build_tv_client` helper now shared with `metadata.rs`'s `fetch_metadata`, so both read the - same stored TV-source setting and credential), + same stored TV-source setting and credential), `sfv.rs` (three + commands backing the SFV panel — `compute_checksums` over a batch + of loaded files with per-file error isolation, `save_checksum_file` + via a native "Save File" dialog, and `verify_checksum_file`, which + reads and parses a picked `.sfv`/`.md5`/`.sha1` file and resolves + each entry's bare filename relative to that file's own parent + directory — all wrapping `aurora-curator-core::sfv`, which does the + actual algorithm/format work), `history.rs` (rename history/undo log), `presets.rs` (up to 9 saved naming schemes) — all three stored as JSON in the OS app-config directory — `cli.rs` (the @@ -219,6 +238,13 @@ src/ React + TypeScript UI (Vite). Faithful recreation of the screen with no settings modal of its own at all — it deliberately has no separate credentials UI, reading whatever TV source/token Fetch Data Settings (in the Rename panel) already has saved. + `SfvPanel.tsx` is the third: load a set of files, compute + checksums in any of CRC32/MD5/SHA1, save them as a sidecar + checksum file via a native Save dialog, or pick an existing + checksum file and verify the files it lists against what's + actually on disk (OK/mismatch/missing per entry) — no settings + modal at all, since the feature needs no credentials or network + access. scripts/gen_icons.py Regenerates src-tauri/icons/ from the real logo mark (`scripts/logo/icon-source.png`, cropped from the supplied lockup) — replace that source image to change the look. @@ -243,7 +269,7 @@ npm run tauri dev npm run tauri build ``` -Verified in the environment this was built in: `cargo test --workspace` (all 145 +Verified in the environment this was built in: `cargo test --workspace` (all 160 passing, including the TMDB, TVmaze, OMDb, and OpenSubtitles client tests — those run against a local mock HTTP server via `mockito`, no live network or API key needed to build/test), `cargo @@ -391,6 +417,30 @@ to its own interface once it's saved (every other credential's "already saved" c does briefly see the real value; the password doesn't), and the Settings panel only ever shows *whether* a password is on file, never the value itself. +## SFV + +The SFV sidebar panel computes and verifies file checksums — CRC32, MD5, or SHA1 — and +reads and writes the classic sidecar checksum-file formats, recreating original +FileBot's SFV panel. Unlike every other panel added this session, it's **entirely +local**: no network access, no credentials, nothing to configure. + +**Load Files…** picks one or more files, then choose a checksum type from the segmented +control (CRC32 defaults to the traditional `.sfv` file format; MD5/SHA1 use the GNU +coreutils `md5sum`/`sha1sum` sidecar convention instead). **Compute** hashes every loaded +file (streamed in 64KB chunks, so even large video files don't need to fit in memory) and +lists each one's checksum, or an **ERROR** badge for any individual file that couldn't be +read — one bad file never aborts the rest of the batch. **Save Checksum File…** opens a +native Save dialog (defaulted to the loaded files' own folder name, e.g. `movies.sfv`) +and writes every successfully-computed checksum out in the chosen format; only files that +computed successfully are included. + +**Verify Checksum File…** goes the other direction: pick an existing `.sfv`/`.md5`/ +`.sha1` file (no need to have loaded its files first) and every entry it lists is +resolved against the real files sitting next to it on disk and reported as **OK**, +**MISMATCH** (actual checksum shown alongside the expected one), or **MISSING** (the +referenced file isn't there). Checksum comparison is case-insensitive, since some tools +write hex digests in uppercase and others in lowercase. + ## Porting roadmap (not yet done) Roughly in priority order — each references the original FileBot source file it's @@ -490,10 +540,12 @@ ported from, for anyone picking this up: 7. **The rest of the UI** — the Rename panel (file loading, drag-drop, live preview, local TV/movie matching, confirmed rename with per-item exclude, rename history/undo, Align Up/Down reordering, naming-scheme Presets with `1`-`9` hotkeys) is built, and so - are the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above) - and the Episodes panel (series search + season browse with a Copy list export, no - credentials of its own, see "Episodes" above) — the first two of the other five - sidebar panels to become real screens. The remaining three (SFV/Filter/List) are + are the Subtitles panel (search + download via OpenSubtitles, see "Subtitles" above), + the Episodes panel (series search + season browse with a Copy list export, no + credentials of its own, see "Episodes" above), and the SFV panel (compute/verify + CRC32/MD5/SHA1 checksums, read/write `.sfv`/`.md5`/`.sha1` sidecar files, entirely + local with no credentials at all, see "SFV" above) — the first three of the other + five sidebar panels to become real screens. The remaining two (Filter/List) are still stub screens. Align Up/Down is button-based (swaps the selected file with its neighbor), not the original's drag-and-drop-within-a-list interaction. diff --git a/crates/aurora-curator-core/Cargo.toml b/crates/aurora-curator-core/Cargo.toml index 9589513..18343c3 100644 --- a/crates/aurora-curator-core/Cargo.toml +++ b/crates/aurora-curator-core/Cargo.toml @@ -25,6 +25,12 @@ regex = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["rt", "macros"] } +# Checksum algorithms for the SFV panel (crc32fast for .sfv files, md-5/sha1 for the +# .md5/.sha1 sidecar-file formats the original FileBot SFV panel also supports). +crc32fast = "1" +md-5 = "0.10" +sha1 = "0.10" + [dev-dependencies] # Local HTTP mock server so the TMDB client's request/response handling can be unit # tested without live network access or a real API key. diff --git a/crates/aurora-curator-core/src/lib.rs b/crates/aurora-curator-core/src/lib.rs index dac13e8..bd2f595 100644 --- a/crates/aurora-curator-core/src/lib.rs +++ b/crates/aurora-curator-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod format; pub mod history; pub mod matching; pub mod rename; +pub mod sfv; pub mod similarity; pub mod web; diff --git a/crates/aurora-curator-core/src/sfv/mod.rs b/crates/aurora-curator-core/src/sfv/mod.rs new file mode 100644 index 0000000..1dbc53a --- /dev/null +++ b/crates/aurora-curator-core/src/sfv/mod.rs @@ -0,0 +1,406 @@ +//! Checksum computation and verification ("SFV" - Simple File Verification), a partial +//! port of `net.filebot.ui.sfv.SfvPanel`. Computes CRC32/MD5/SHA-1 checksums for a set +//! of files, builds a sidecar checksum-file's text content (`.sfv`/`.md5`/`.sha1`), and +//! verifies a set of files against an already-parsed checksum file - flagging each +//! referenced file OK, mismatched, or missing on disk. Deliberately local/offline only, +//! unlike every other feature added this session - no web client, no credentials. + +use std::fmt; +use std::fs::File; +use std::io::{self, Read}; +use std::path::Path; + +use md5::{Digest, Md5}; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; + +const READ_BUFFER_SIZE: usize = 64 * 1024; + +/// Which checksum algorithm/file format is in play. `Crc32` is the traditional ".sfv" +/// format (whole-file CRC32, 8 uppercase hex digits); `Md5`/`Sha1` follow the GNU +/// coreutils `md5sum`/`sha1sum` sidecar-file convention instead (` `). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ChecksumKind { + Crc32, + Md5, + Sha1, +} + +impl ChecksumKind { + /// The sidecar file extension conventionally used for this kind (no leading dot). + pub fn extension(&self) -> &'static str { + match self { + ChecksumKind::Crc32 => "sfv", + ChecksumKind::Md5 => "md5", + ChecksumKind::Sha1 => "sha1", + } + } + + /// A human-readable label for UI display. + pub fn label(&self) -> &'static str { + match self { + ChecksumKind::Crc32 => "CRC32", + ChecksumKind::Md5 => "MD5", + ChecksumKind::Sha1 => "SHA-1", + } + } +} + +impl fmt::Display for ChecksumKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} + +/// Streams `path` and returns its checksum as hex text - uppercase for CRC32 (matching +/// traditional `.sfv` file convention), lowercase for MD5/SHA-1 (matching +/// `md5sum`/`sha1sum`). Streams in fixed-size chunks rather than reading the whole file +/// into memory at once, so this scales to large video files without a huge allocation. +pub fn compute_checksum(path: &Path, kind: ChecksumKind) -> io::Result { + let mut file = File::open(path)?; + let mut buf = [0u8; READ_BUFFER_SIZE]; + + match kind { + ChecksumKind::Crc32 => { + let mut hasher = crc32fast::Hasher::new(); + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:08X}", hasher.finalize())) + } + ChecksumKind::Md5 => { + let mut hasher = Md5::new(); + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) + } + ChecksumKind::Sha1 => { + let mut hasher = Sha1::new(); + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) + } + } +} + +/// 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, +/// always relative to wherever the checksum file itself lives) and the checksum text +/// as written in the file, normalized to the kind's own casing convention. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChecksumEntry { + pub file_name: String, + pub checksum: String, +} + +/// Parses the text content of a checksum file into its entries, dispatching on format +/// by `kind` (`.sfv`'s `name checksum` layout vs. the coreutils `checksum name` one). +pub fn parse_checksum_file(content: &str, kind: ChecksumKind) -> Vec { + match kind { + ChecksumKind::Crc32 => parse_sfv(content), + ChecksumKind::Md5 | ChecksumKind::Sha1 => parse_hashsum(content), + } +} + +/// `.sfv` format: one entry per line, `<8-hex-digit CRC32>`. +/// Blank lines and comment lines (leading `;`, the format's own convention) are +/// skipped. The filename is whatever comes before the LAST run of whitespace on the +/// line, so a filename containing spaces still parses correctly. +fn parse_sfv(content: &str) -> Vec { + content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with(';')) + .filter_map(|line| { + let (name, checksum) = line.rsplit_once(char::is_whitespace)?; + let name = name.trim(); + if name.is_empty() { + return None; + } + Some(ChecksumEntry { + file_name: name.to_string(), + checksum: checksum.trim().to_uppercase(), + }) + }) + .collect() +} + +/// `.md5`/`.sha1` format (GNU coreutils `md5sum`/`sha1sum` convention): one entry per +/// line, ``. +fn parse_hashsum(content: &str) -> Vec { + content + .lines() + .map(str::trim_end) + .filter(|line| !line.is_empty()) + .filter_map(|line| { + let mut parts = line.splitn(2, char::is_whitespace); + let checksum = parts.next()?.trim(); + let rest = parts.next()?.trim_start(); + let name = rest.strip_prefix('*').unwrap_or(rest); + if checksum.is_empty() || name.is_empty() { + return None; + } + Some(ChecksumEntry { + file_name: name.to_string(), + checksum: checksum.to_lowercase(), + }) + }) + .collect() +} + +/// Builds the text content of a checksum file from a set of entries, in the given +/// kind's own format/casing convention - the inverse of `parse_checksum_file`. +pub fn build_checksum_file(entries: &[ChecksumEntry], kind: ChecksumKind) -> String { + let mut out = String::new(); + match kind { + ChecksumKind::Crc32 => { + out.push_str("; Generated by Aurora Curator\n"); + for entry in entries { + out.push_str(&entry.file_name); + out.push(' '); + out.push_str(&entry.checksum.to_uppercase()); + out.push('\n'); + } + } + ChecksumKind::Md5 | ChecksumKind::Sha1 => { + for entry in entries { + out.push_str(&entry.checksum.to_lowercase()); + out.push_str(" "); + out.push_str(&entry.file_name); + out.push('\n'); + } + } + } + out +} + +/// The outcome of checking one checksum-file entry against the actual file on disk. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum VerifyStatus { + Ok, + Mismatch, + /// The checksum file lists this name, but no such file exists at the resolved path. + Missing, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyResult { + pub file_name: String, + pub expected: String, + /// `None` only for `Missing` - there was nothing to hash. + pub actual: Option, + pub status: VerifyStatus, +} + +/// Verifies each parsed entry against the real file on disk, resolved as +/// `base_dir.join(&entry.file_name)` - checksum files almost always reference files by +/// bare name, sitting alongside them, so `base_dir` is normally the checksum file's own +/// parent directory. +pub fn verify_entries(entries: &[ChecksumEntry], base_dir: &Path, kind: ChecksumKind) -> Vec { + entries + .iter() + .map(|entry| { + let path = base_dir.join(&entry.file_name); + match compute_checksum(&path, kind) { + Ok(actual) => { + let status = if actual.eq_ignore_ascii_case(&entry.checksum) { + VerifyStatus::Ok + } else { + VerifyStatus::Mismatch + }; + VerifyResult { + file_name: entry.file_name.clone(), + expected: entry.checksum.clone(), + actual: Some(actual), + status, + } + } + Err(_) => VerifyResult { + file_name: entry.file_name.clone(), + expected: entry.checksum.clone(), + actual: None, + status: VerifyStatus::Missing, + }, + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::atomic::{AtomicU32, Ordering}; + + static COUNTER: AtomicU32 = AtomicU32::new(0); + + fn temp_dir() -> std::path::PathBuf { + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("aurora_curator_sfv_test_{}_{}", std::process::id(), n)); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn crc32_matches_the_well_known_check_value() { + // "123456789" is the standard CRC32 (IEEE) test vector - checksum CBF43926. + let dir = temp_dir(); + let path = dir.join("check.txt"); + fs::write(&path, b"123456789").unwrap(); + assert_eq!(compute_checksum(&path, ChecksumKind::Crc32).unwrap(), "CBF43926"); + } + + #[test] + fn md5_matches_a_known_hash() { + // md5("") = d41d8cd98f00b204e9800998ecf8427e (the empty-string test vector). + let dir = temp_dir(); + let path = dir.join("empty.txt"); + fs::write(&path, b"").unwrap(); + assert_eq!(compute_checksum(&path, ChecksumKind::Md5).unwrap(), "d41d8cd98f00b204e9800998ecf8427e"); + } + + #[test] + fn sha1_matches_a_known_hash() { + // sha1("abc") = a9993e364706816aba3e25717850c26c9cd0d89d (the classic FIPS 180 test vector). + let dir = temp_dir(); + let path = dir.join("abc.txt"); + fs::write(&path, b"abc").unwrap(); + assert_eq!(compute_checksum(&path, ChecksumKind::Sha1).unwrap(), "a9993e364706816aba3e25717850c26c9cd0d89d"); + } + + #[test] + fn compute_checksum_errors_when_the_file_does_not_exist() { + let dir = temp_dir(); + let missing = dir.join("nope.txt"); + assert!(compute_checksum(&missing, ChecksumKind::Crc32).is_err()); + } + + #[test] + 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 entries = parse_checksum_file(content, ChecksumKind::Crc32); + assert_eq!( + entries, + vec![ + ChecksumEntry { file_name: "Show.S01E01.mkv".to_string(), checksum: "CBF43926".to_string() }, + ChecksumEntry { file_name: "Show.S01E02.mkv".to_string(), checksum: "12345678".to_string() }, + ] + ); + } + + #[test] + fn sfv_filenames_with_spaces_still_parse_correctly() { + let content = "My Show S01E01.mkv CBF43926\n"; + let entries = parse_checksum_file(content, ChecksumKind::Crc32); + assert_eq!(entries, vec![ChecksumEntry { file_name: "My Show S01E01.mkv".to_string(), checksum: "CBF43926".to_string() }]); + } + + #[test] + fn parses_a_typical_md5sum_style_file_including_binary_mode_marker() { + let content = "d41d8cd98f00b204e9800998ecf8427e empty.txt\na9993e364706816aba3e25717850c26c9cd0d89d *abc.txt\n"; + let entries = parse_checksum_file(content, ChecksumKind::Sha1); + assert_eq!( + entries, + vec![ + ChecksumEntry { file_name: "empty.txt".to_string(), checksum: "d41d8cd98f00b204e9800998ecf8427e".to_string() }, + ChecksumEntry { file_name: "abc.txt".to_string(), checksum: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string() }, + ] + ); + } + + #[test] + fn build_checksum_file_round_trips_through_parse_for_sfv() { + let entries = vec![ + ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "cbf43926".to_string() }, + ChecksumEntry { file_name: "b.mkv".to_string(), checksum: "deadbeef".to_string() }, + ]; + let text = build_checksum_file(&entries, ChecksumKind::Crc32); + let parsed = parse_checksum_file(&text, ChecksumKind::Crc32); + assert_eq!( + parsed, + vec![ + ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "CBF43926".to_string() }, + ChecksumEntry { file_name: "b.mkv".to_string(), checksum: "DEADBEEF".to_string() }, + ] + ); + } + + #[test] + fn build_checksum_file_round_trips_through_parse_for_md5() { + let entries = vec![ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "D41D8CD98F00B204E9800998ECF8427E".to_string() }]; + let text = build_checksum_file(&entries, ChecksumKind::Md5); + let parsed = parse_checksum_file(&text, ChecksumKind::Md5); + assert_eq!(parsed, vec![ChecksumEntry { file_name: "a.mkv".to_string(), checksum: "d41d8cd98f00b204e9800998ecf8427e".to_string() }]); + } + + #[test] + fn verify_entries_reports_ok_for_a_matching_file() { + let dir = temp_dir(); + fs::write(dir.join("a.txt"), b"abc").unwrap(); + let entries = vec![ChecksumEntry { + file_name: "a.txt".to_string(), + checksum: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(), + }]; + let results = verify_entries(&entries, &dir, ChecksumKind::Sha1); + assert_eq!(results.len(), 1); + assert_eq!(results[0].status, VerifyStatus::Ok); + assert_eq!(results[0].actual.as_deref(), Some("a9993e364706816aba3e25717850c26c9cd0d89d")); + } + + #[test] + fn verify_entries_reports_mismatch_for_a_changed_file() { + let dir = temp_dir(); + fs::write(dir.join("a.txt"), b"changed content").unwrap(); + let entries = vec![ChecksumEntry { + file_name: "a.txt".to_string(), + checksum: "a9993e364706816aba3e25717850c26c9cd0d89d".to_string(), + }]; + let results = verify_entries(&entries, &dir, ChecksumKind::Sha1); + assert_eq!(results[0].status, VerifyStatus::Mismatch); + assert!(results[0].actual.is_some()); + } + + #[test] + fn verify_entries_reports_missing_for_a_file_that_does_not_exist() { + let dir = temp_dir(); + let entries = vec![ChecksumEntry { file_name: "nope.txt".to_string(), checksum: "CBF43926".to_string() }]; + let results = verify_entries(&entries, &dir, ChecksumKind::Crc32); + assert_eq!(results[0].status, VerifyStatus::Missing); + assert_eq!(results[0].actual, None); + } + + #[test] + fn verify_is_case_insensitive_on_the_checksum_text() { + let dir = temp_dir(); + fs::write(dir.join("a.txt"), b"123456789").unwrap(); + let entries = vec![ChecksumEntry { file_name: "a.txt".to_string(), checksum: "cbf43926".to_string() }]; + let results = verify_entries(&entries, &dir, ChecksumKind::Crc32); + assert_eq!(results[0].status, VerifyStatus::Ok); + } + + #[test] + fn checksum_kind_extension_and_label_match_convention() { + assert_eq!(ChecksumKind::Crc32.extension(), "sfv"); + assert_eq!(ChecksumKind::Md5.extension(), "md5"); + assert_eq!(ChecksumKind::Sha1.extension(), "sha1"); + assert_eq!(ChecksumKind::Crc32.label(), "CRC32"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dfca365..eea8bbe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod history; mod metadata; mod presets; mod settings; +mod sfv; mod subtitles; use aurora_curator_core::format::ExpressionFormat; @@ -117,6 +118,9 @@ pub fn run() { subtitles::search_subtitles, subtitles::download_subtitle, episodes::browse_episodes, + sfv::compute_checksums, + sfv::save_checksum_file, + sfv::verify_checksum_file, history::list_rename_history, history::undo_rename_batch, presets::list_presets, diff --git a/src-tauri/src/sfv.rs b/src-tauri/src/sfv.rs new file mode 100644 index 0000000..7efd882 --- /dev/null +++ b/src-tauri/src/sfv.rs @@ -0,0 +1,133 @@ +//! The Tauri-facing side of the SFV panel: compute checksums for a set of loaded +//! files, save them as a sidecar checksum file (`.sfv`/`.md5`/`.sha1`), and verify a +//! set of files against an existing checksum file. Wraps `aurora_curator_core::sfv`, +//! which does all the actual algorithm/format work - this module is just Tauri +//! commands, native file dialogs, and the async/off-main-thread plumbing. Fully local: +//! unlike every other panel added this session, SFV needs no network access and no +//! credentials at all. + +use std::path::{Path, PathBuf}; + +use aurora_curator_core::sfv::{self, ChecksumEntry, ChecksumKind, VerifyResult}; +use serde::Serialize; +use tauri::AppHandle; +use tauri_plugin_dialog::DialogExt; + +/// One computed checksum, or the error hashing that particular file hit (a file +/// disappearing between being loaded and being hashed, a permissions problem, etc) - +/// kept per-file so one bad file doesn't abort the whole batch. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ChecksumOutcome { + pub path: String, + pub name: String, + pub checksum: Option, + pub error: Option, +} + +fn file_name_of(path: &str) -> String { + Path::new(path) + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()) +} + +/// Computes a checksum for every path in `paths`, using the algorithm `kind` selects. +/// Runs the whole batch on a blocking thread (real file I/O, potentially large video +/// files) rather than the async command-invocation path. +#[tauri::command] +pub async fn compute_checksums(paths: Vec, kind: ChecksumKind) -> Vec { + tauri::async_runtime::spawn_blocking(move || { + paths + .into_iter() + .map(|path| { + let name = file_name_of(&path); + match sfv::compute_checksum(Path::new(&path), kind) { + Ok(checksum) => ChecksumOutcome { path, name, checksum: Some(checksum), error: None }, + Err(err) => ChecksumOutcome { path, name, checksum: 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 +/// input shape `save_checksum_file` builds a sidecar file's text content from. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChecksumFileRow { + pub name: String, + pub checksum: String, +} + +/// Opens a native "Save File" dialog defaulted to `.`, +/// and if the user doesn't cancel, writes the built checksum-file text there. Returns +/// the saved path, or `None` if the user cancelled the dialog (not an error). +#[tauri::command] +pub async fn save_checksum_file(app: AppHandle, default_name: String, rows: Vec, kind: ChecksumKind) -> Result, String> { + let entries: Vec = rows.into_iter().map(|r| ChecksumEntry { file_name: r.name, checksum: r.checksum }).collect(); + let content = sfv::build_checksum_file(&entries, kind); + + let (tx, rx) = tokio::sync::oneshot::channel(); + app.dialog() + .file() + .set_file_name(format!("{default_name}.{}", kind.extension())) + .save_file(move |result| { + let _ = tx.send(result); + }); + let chosen = rx.await.map_err(|e| e.to_string())?; + + let Some(file_path) = chosen else { + return Ok(None); + }; + let path = file_path.into_path().map_err(|e| e.to_string())?; + + tauri::async_runtime::spawn_blocking({ + let path = path.clone(); + move || std::fs::write(&path, content) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + Ok(Some(path.to_string_lossy().to_string())) +} + +/// What verifying one checksum file produced: the file's own directory (every entry is +/// resolved relative to it) and the per-entry OK/mismatch/missing results. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyOutcome { + pub base_dir: String, + pub results: Vec, +} + +/// Reads and parses `checksum_file_path` (a `.sfv`/`.md5`/`.sha1` file the user picked) +/// and verifies every entry it lists against the real files on disk, resolved relative +/// to the checksum file's own parent directory. +#[tauri::command] +pub async fn verify_checksum_file(checksum_file_path: String, kind: ChecksumKind) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let path = PathBuf::from(&checksum_file_path); + let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; + let entries = sfv::parse_checksum_file(&content, kind); + let base_dir = path.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from(".")); + let results = sfv::verify_entries(&entries, &base_dir, kind); + Ok(VerifyOutcome { base_dir: base_dir.to_string_lossy().to_string(), results }) + }) + .await + .map_err(|e| e.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_name_of_strips_the_directory_portion() { + assert_eq!(file_name_of("/a/b/c.mkv"), "c.mkv"); + assert_eq!(file_name_of("just-a-name.mkv"), "just-a-name.mkv"); + } +} diff --git a/src/App.tsx b/src/App.tsx index 7938c7a..b3a4363 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import Header from "./components/Header"; import RenamePanel from "./components/RenamePanel"; import EpisodesPanel from "./components/EpisodesPanel"; import SubtitlesPanel from "./components/SubtitlesPanel"; +import SfvPanel from "./components/SfvPanel"; import PanelStub from "./components/PanelStub"; export default function App() { @@ -22,6 +23,8 @@ export default function App() { ) : selected === "subtitles" ? ( + ) : selected === "sfv" ? ( + ) : ( )} diff --git a/src/components/SfvPanel.css b/src/components/SfvPanel.css new file mode 100644 index 0000000..7f9d01e --- /dev/null +++ b/src/components/SfvPanel.css @@ -0,0 +1,105 @@ +.sfv-panel { + height: 100%; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; + padding: 18px; + overflow-y: auto; +} + +.sfv-toolbar { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; + background: var(--card); + border: 1px solid var(--surface-deep); + border-radius: 12px; + padding: 14px; +} + +.sfv-loaded-count { + font-size: 12px; + color: var(--muted-text); +} + +.sfv-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.sfv-results { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.sfv-row { + display: flex; + align-items: center; + gap: 12px; + background: var(--card); + border: 1px solid var(--surface-deep); + border-radius: 10px; + padding: 9px 12px; +} + +.sfv-row-name { + flex: 1; + min-width: 0; + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sfv-row-checksum { + flex: 0 0 auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 12px; + color: var(--aurora-cyan); +} + +.sfv-row-checksum-muted { + color: var(--muted-text); +} + +.sfv-badge { + flex: 0 0 auto; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.04em; + border-radius: 999px; + padding: 3px 9px; +} + +.sfv-badge-ok { + background: rgba(52, 211, 153, 0.16); + color: #34d399; +} + +.sfv-badge-mismatch { + background: rgba(248, 113, 113, 0.16); + color: #f87171; +} + +.sfv-badge-missing { + background: rgba(250, 204, 21, 0.16); + color: #facc15; +} + +.sfv-empty-note { + color: var(--muted-text); + font-size: 12.5px; + padding: 8px 2px; +} + +.sfv-status { + font-size: 12px; + color: var(--aurora-cyan); +} diff --git a/src/components/SfvPanel.tsx b/src/components/SfvPanel.tsx new file mode 100644 index 0000000..72ac809 --- /dev/null +++ b/src/components/SfvPanel.tsx @@ -0,0 +1,197 @@ +import { useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; + +interface FileEntry { + path: string; + name: string; + ext: string; +} + +type ChecksumKind = "crc32" | "md5" | "sha1"; + +const KIND_OPTIONS: { value: ChecksumKind; label: string }[] = [ + { value: "crc32", label: "CRC32 (.sfv)" }, + { value: "md5", label: "MD5" }, + { value: "sha1", label: "SHA-1" }, +]; + +interface ChecksumOutcome { + path: string; + name: string; + checksum: string | null; + error: string | null; +} + +type VerifyStatus = "ok" | "mismatch" | "missing"; + +interface VerifyResult { + fileName: string; + expected: string; + actual: string | null; + status: VerifyStatus; +} + +interface VerifyOutcome { + baseDir: string; + results: VerifyResult[]; +} + +const STATUS_LABELS: Record = { + ok: "OK", + mismatch: "MISMATCH", + missing: "MISSING", +}; + +function defaultBaseName(files: FileEntry[]): string { + if (files.length === 0) return "checksums"; + const dir = files[0].path.replace(/[\\/][^\\/]*$/, ""); + const folderName = dir.split(/[\\/]/).pop(); + return folderName && folderName.trim() ? folderName : "checksums"; +} + +export default function SfvPanel() { + const [files, setFiles] = useState([]); + const [kind, setKind] = useState("crc32"); + const [computing, setComputing] = useState(false); + const [computeResults, setComputeResults] = useState(null); + const [verifyOutcome, setVerifyOutcome] = useState(null); + const [verifying, setVerifying] = useState(false); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState(null); + + async function loadFiles() { + const picked = await invoke("pick_files"); + if (picked.length === 0) return; + setFiles(picked); + setComputeResults(null); + setVerifyOutcome(null); + setStatus(null); + } + + async function runCompute() { + if (files.length === 0) { + setStatus("Load files first."); + return; + } + setComputing(true); + setVerifyOutcome(null); + try { + const results = await invoke("compute_checksums", { + paths: files.map((f) => f.path), + kind, + }); + setComputeResults(results); + const failed = results.filter((r) => r.error).length; + setStatus(failed > 0 ? `Computed ${results.length - failed} of ${results.length} (${failed} failed).` : `Computed ${results.length} checksum${results.length === 1 ? "" : "s"}.`); + } catch (err) { + setStatus(String(err)); + } finally { + setComputing(false); + } + } + + async function saveChecksumFile() { + if (!computeResults || computeResults.length === 0) { + setStatus("Compute checksums first."); + return; + } + const rows = computeResults.filter((r) => r.checksum).map((r) => ({ name: r.name, checksum: r.checksum as string })); + if (rows.length === 0) { + setStatus("No successfully computed checksums to save."); + return; + } + setSaving(true); + try { + const savedPath = await invoke("save_checksum_file", { + defaultName: defaultBaseName(files), + rows, + kind, + }); + setStatus(savedPath ? `Saved to ${savedPath}` : "Save cancelled."); + } catch (err) { + setStatus(String(err)); + } finally { + setSaving(false); + } + } + + async function verifyChecksumFile() { + const picked = await invoke("pick_files"); + if (picked.length === 0) return; + const checksumFile = picked[0]; + setVerifying(true); + setComputeResults(null); + try { + const outcome = await invoke("verify_checksum_file", { + checksumFilePath: checksumFile.path, + kind, + }); + setVerifyOutcome(outcome); + const ok = outcome.results.filter((r) => r.status === "ok").length; + const mismatch = outcome.results.filter((r) => r.status === "mismatch").length; + const missing = outcome.results.filter((r) => r.status === "missing").length; + setStatus(`${ok} OK, ${mismatch} mismatch${mismatch === 1 ? "" : "es"}, ${missing} missing.`); + } catch (err) { + setStatus(String(err)); + } finally { + setVerifying(false); + } + } + + return ( +
+
+ + {files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"} +
+ {KIND_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+ + + +
+ +
+ {!computeResults && !verifyOutcome &&
Load files and Compute, or pick an existing checksum file to Verify.
} + + {computeResults && + computeResults.map((r) => ( +
+ {r.name} + {r.checksum ? {r.checksum} : ERROR} +
+ ))} + + {verifyOutcome && + verifyOutcome.results.map((r) => ( +
+ {r.fileName} + {r.actual && r.status === "mismatch" && ( + + {r.actual} ≠ {r.expected} + + )} + {STATUS_LABELS[r.status]} +
+ ))} +
+ + {status &&
{status}
} +
+ ); +} diff --git a/src/main.tsx b/src/main.tsx index 1430920..4cb895a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,6 +8,7 @@ import "./components/Header.css"; import "./components/RenamePanel.css"; import "./components/EpisodesPanel.css"; import "./components/SubtitlesPanel.css"; +import "./components/SfvPanel.css"; import "./components/PanelStub.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(