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.
This commit is contained in:
Renamr Scaffold
2026-08-29 15:59:05 +00:00
parent ed26391651
commit 316dbfc1ff
11 changed files with 940 additions and 8 deletions
Generated
+24
View File
@@ -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"
+60 -8
View File
@@ -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,
`<name> <checksum>` per line) and the GNU coreutils
`md5sum`/`sha1sum` layout (`<checksum> <name>`) 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.
+6
View File
@@ -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.
+1
View File
@@ -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;
+406
View File
@@ -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 (`<hex> <filename>`).
#[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<String> {
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<ChecksumEntry> {
match kind {
ChecksumKind::Crc32 => parse_sfv(content),
ChecksumKind::Md5 | ChecksumKind::Sha1 => parse_hashsum(content),
}
}
/// `.sfv` format: one entry per line, `<filename><whitespace><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<ChecksumEntry> {
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, `<hex checksum><two spaces, or one space + '*' for binary mode><filename>`.
fn parse_hashsum(content: &str) -> Vec<ChecksumEntry> {
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<String>,
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<VerifyResult> {
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");
}
}
+4
View File
@@ -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,
+133
View File
@@ -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<String>,
pub error: Option<String>,
}
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<String>, kind: ChecksumKind) -> Vec<ChecksumOutcome> {
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 `<default_name>.<kind's extension>`,
/// 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<ChecksumFileRow>, kind: ChecksumKind) -> Result<Option<String>, String> {
let entries: Vec<ChecksumEntry> = 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<VerifyResult>,
}
/// 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<VerifyOutcome, String> {
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");
}
}
+3
View File
@@ -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() {
<EpisodesPanel />
) : selected === "subtitles" ? (
<SubtitlesPanel />
) : selected === "sfv" ? (
<SfvPanel />
) : (
<PanelStub name={current.name} />
)}
+105
View File
@@ -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);
}
+197
View File
@@ -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<VerifyStatus, string> = {
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<FileEntry[]>([]);
const [kind, setKind] = useState<ChecksumKind>("crc32");
const [computing, setComputing] = useState(false);
const [computeResults, setComputeResults] = useState<ChecksumOutcome[] | null>(null);
const [verifyOutcome, setVerifyOutcome] = useState<VerifyOutcome | null>(null);
const [verifying, setVerifying] = useState(false);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<string | null>(null);
async function loadFiles() {
const picked = await invoke<FileEntry[]>("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<ChecksumOutcome[]>("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<string | null>("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<FileEntry[]>("pick_files");
if (picked.length === 0) return;
const checksumFile = picked[0];
setVerifying(true);
setComputeResults(null);
try {
const outcome = await invoke<VerifyOutcome>("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 (
<div className="sfv-panel">
<div className="sfv-toolbar">
<button className="action-button" onClick={loadFiles}>
Load Files…
</button>
<span className="sfv-loaded-count">{files.length > 0 ? `${files.length} file${files.length === 1 ? "" : "s"} loaded` : "No files loaded"}</span>
<div className="action-segmented" role="radiogroup" aria-label="Checksum type">
{KIND_OPTIONS.map((opt) => (
<button key={opt.value} className={kind === opt.value ? "selected" : ""} onClick={() => setKind(opt.value)}>
{opt.label}
</button>
))}
</div>
</div>
<div className="sfv-actions">
<button className="action-button primary" onClick={runCompute} disabled={computing || files.length === 0}>
{computing ? "Computing…" : "Compute"}
</button>
<button className="action-button" onClick={saveChecksumFile} disabled={saving || !computeResults}>
{saving ? "Saving…" : "Save Checksum File…"}
</button>
<button className="action-button" onClick={verifyChecksumFile} disabled={verifying}>
{verifying ? "Verifying…" : "Verify Checksum File…"}
</button>
</div>
<div className="sfv-results">
{!computeResults && !verifyOutcome && <div className="sfv-empty-note">Load files and Compute, or pick an existing checksum file to Verify.</div>}
{computeResults &&
computeResults.map((r) => (
<div className="sfv-row" key={r.path}>
<span className="sfv-row-name">{r.name}</span>
{r.checksum ? <span className="sfv-row-checksum">{r.checksum}</span> : <span className="sfv-badge sfv-badge-missing">ERROR</span>}
</div>
))}
{verifyOutcome &&
verifyOutcome.results.map((r) => (
<div className="sfv-row" key={r.fileName}>
<span className="sfv-row-name">{r.fileName}</span>
{r.actual && r.status === "mismatch" && (
<span className="sfv-row-checksum sfv-row-checksum-muted">
{r.actual} ≠ {r.expected}
</span>
)}
<span className={`sfv-badge sfv-badge-${r.status}`}>{STATUS_LABELS[r.status]}</span>
</div>
))}
</div>
{status && <div className="sfv-status">{status}</div>}
</div>
);
}
+1
View File
@@ -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(