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:
Generated
+24
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user