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:
@@ -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