Perceptual-hash dedup: web UI + background job (3.7, part 2)

Wires the fingerprint engine into the web Maintenance view.

- web.rs: a DedupState job (one at a time) on its own thread. POST
  /api/maintenance/dedup/scan snapshots the library, mtime-gates against
  stored fingerprints, fingerprints the new/changed videos in parallel
  (progress counter), prunes vanished entries, groups by visual
  similarity, and builds review rows (title/channel/size/files + a
  recommended-keep = largest copy). GET /api/maintenance/dedup/status
  polls progress + results. Deletion reuses /api/maintenance/remove.
- index.html: a "Similar content (perceptual)" section in the Maintenance
  modal — Scan button, live progress bar, grouped results with
  checkboxes (recommended-keep pre-unchecked), and a re-scan that drops
  deleted copies. Poller self-cancels when the modal closes.

Integration test (ffmpeg-gated): generates orig + CRF-38 downscaled
re-encode + an unrelated clip, runs the real scan end-to-end, and asserts
the first two group while the third stays out. 118 tests pass.

Desktop Maintenance UI next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-06-07 06:17:58 -07:00
parent 6d5c1cae33
commit f30e32707e
3 changed files with 293 additions and 1 deletions

View file

@ -24,7 +24,7 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path as StdPath, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use axum::{
@ -133,6 +133,43 @@ pub struct WebState {
/// re-serializing. The Arc lets concurrent responses share one
/// allocation. Cleared / replaced lazily on the next miss.
pub library_body_cache: Mutex<Option<(u64, std::sync::Arc<String>)>>,
/// Background perceptual-dedup job state. The expensive fingerprint pass
/// runs on its own thread; this carries live progress + the last result
/// so `/api/maintenance/dedup/status` can be polled. `Arc` so the worker
/// thread can hold a handle past the request that started it.
pub dedup: std::sync::Arc<DedupState>,
}
/// Live state for the background perceptual-dedup job (see
/// [`post_dedup_scan`]). One job runs at a time, guarded by `running`.
#[derive(Default)]
pub struct DedupState {
running: AtomicBool,
done: AtomicUsize,
total: AtomicUsize,
result: Mutex<Option<Vec<SimilarGroup>>>,
error: Mutex<Option<String>>,
}
/// One video in a perceptual-similarity group, with everything the review UI
/// needs to show it and (optionally) delete its files.
#[derive(serde::Serialize, Clone)]
pub struct SimilarVideo {
video_id: String,
title: String,
channel: String,
location: String,
file_size: Option<u64>,
/// Absolute paths of the video + its sidecars, for the delete action.
files: Vec<String>,
/// The copy the UI recommends keeping (largest file in the group).
recommended_keep: bool,
}
/// A cluster of videos that share visual content across different IDs.
#[derive(serde::Serialize, Clone)]
pub struct SimilarGroup {
videos: Vec<SimilarVideo>,
}
/// Failed-login tracking entry. After [`LOGIN_LOCKOUT_AFTER`] failures from
@ -1976,6 +2013,132 @@ struct RemoveRequest {
paths: Vec<PathBuf>,
}
/// `POST /api/maintenance/dedup/scan` — start (or no-op if already running)
/// the background perceptual-dedup pass. Fingerprints any new/changed videos
/// (cached by mtime), then groups by visual similarity. Returns immediately;
/// poll `/api/maintenance/dedup/status` for progress + results.
async fn post_dedup_scan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
if state.dedup.running.swap(true, Ordering::SeqCst) {
return Json(serde_json::json!({ "started": false, "running": true }));
}
state.dedup.done.store(0, Ordering::Relaxed);
state.dedup.total.store(0, Ordering::Relaxed);
*state.dedup.error.lock_recover() = None;
// Snapshot what the worker needs from the library (cheap clones of paths +
// display strings) so it doesn't hold the lock during the ffmpeg pass.
let (inputs, by_path, valid_paths) = {
let lib = state.library.lock_recover();
let root = state.library_root.clone();
let mut inputs: Vec<crate::fingerprint::FpInput> = Vec::new();
let mut by_path: HashMap<String, SimilarVideo> = HashMap::new();
let mut valid_paths: HashSet<String> = HashSet::new();
for ch in lib.iter() {
let channel = ch.name.clone();
let videos = ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter()));
for v in videos {
let Some(vp) = &v.video_path else { continue }; // need a real file
let path_str = vp.display().to_string();
valid_paths.insert(path_str.clone());
inputs.push(crate::fingerprint::FpInput {
path: vp.clone(),
mtime_unix: v.mtime_unix.map(|m| m as i64).unwrap_or(0),
video_id: v.id.clone(),
duration_secs: v.duration_secs.unwrap_or(0.0),
});
let mut files = vec![path_str.clone()];
for p in [v.thumb_path.as_ref(), v.info_path.as_ref(), v.description_path.as_ref()]
.into_iter().flatten() { files.push(p.display().to_string()); }
for s in &v.subtitles { files.push(s.path.display().to_string()); }
let location = vp.parent()
.map(|d| d.strip_prefix(&root).unwrap_or(d).display().to_string())
.unwrap_or_default();
by_path.insert(path_str, SimilarVideo {
video_id: v.id.clone(), title: v.title.clone(), channel: channel.clone(),
location, file_size: v.file_size, files, recommended_keep: false,
});
}
}
(inputs, by_path, valid_paths)
};
let db = state.db.clone();
let dedup = state.dedup.clone();
std::thread::spawn(move || run_dedup(db, dedup, inputs, by_path, valid_paths));
Json(serde_json::json!({ "started": true }))
}
/// `GET /api/maintenance/dedup/status` — progress + results of the dedup job.
async fn get_dedup_status(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let d = &state.dedup;
Json(serde_json::json!({
"running": d.running.load(Ordering::Relaxed),
"done": d.done.load(Ordering::Relaxed),
"total": d.total.load(Ordering::Relaxed),
"groups": d.result.lock_recover().clone(),
"error": d.error.lock_recover().clone(),
}))
}
/// Worker body for the dedup job: mtime-gate, fingerprint the missing videos
/// in parallel, prune vanished entries, then group by visual similarity and
/// stash the result. Always clears `running` on exit.
fn run_dedup(
db: Database,
dedup: std::sync::Arc<DedupState>,
inputs: Vec<crate::fingerprint::FpInput>,
by_path: HashMap<String, SimilarVideo>,
valid_paths: HashSet<String>,
) {
use crate::fingerprint;
let outcome: Result<Vec<SimilarGroup>, String> = (|| {
let known = db.fingerprint_mtimes().map_err(|e| e.to_string())?;
let todo: Vec<fingerprint::FpInput> = inputs
.into_iter()
.filter(|i| known.get(&i.path.display().to_string()) != Some(&i.mtime_unix))
.collect();
dedup.total.store(todo.len(), Ordering::Relaxed);
let workers = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4);
let computed = fingerprint::compute_batch(todo, workers, &dedup.done);
for c in &computed {
let _ = db.upsert_fingerprint(
&c.input.path.display().to_string(), c.input.mtime_unix,
&c.input.video_id, c.input.duration_secs, &c.hashes,
);
}
let _ = db.prune_fingerprints(&valid_paths);
let stored = db.load_fingerprints().map_err(|e| e.to_string())?;
let records: Vec<fingerprint::FpRecord> = stored.iter().map(|s| fingerprint::FpRecord {
video_id: s.video_id.clone(), duration_secs: s.duration_secs, hashes: s.hashes.clone(),
}).collect();
let groups_idx = fingerprint::group_similar(
&records, fingerprint::DEFAULT_DUR_TOL,
fingerprint::DEFAULT_MAX_HAM, fingerprint::DEFAULT_MIN_MATCH,
);
let mut groups = Vec::new();
for g in groups_idx {
let mut vids: Vec<SimilarVideo> =
g.iter().filter_map(|&i| by_path.get(&stored[i].path).cloned()).collect();
if vids.len() < 2 { continue; } // stale paths dropped out
// Recommend keeping the largest file (best quality) in each group.
let keep = vids.iter().enumerate()
.max_by_key(|(_, v)| v.file_size.unwrap_or(0)).map(|(i, _)| i);
for (i, v) in vids.iter_mut().enumerate() { v.recommended_keep = Some(i) == keep; }
groups.push(SimilarGroup { videos: vids });
}
Ok(groups)
})();
match outcome {
Ok(groups) => *dedup.result.lock_recover() = Some(groups),
Err(e) => *dedup.error.lock_recover() = Some(e),
}
dedup.running.store(false, Ordering::SeqCst);
}
/// `POST /api/maintenance/remove` — delete the listed duplicate files.
/// Paths outside the library root are refused.
async fn post_maintenance_remove(
@ -2611,6 +2774,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
progress_tx,
login_attempts: Mutex::new(HashMap::new()),
library_body_cache: Mutex::new(None),
dedup: std::sync::Arc::new(DedupState::default()),
});
// Broadcast progress snapshots to WebSocket subscribers. Ticks fast
@ -2721,6 +2885,8 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
.route("/api/plex/generate", post(post_plex_generate))
.route("/api/maintenance/scan", get(get_maintenance_scan))
.route("/api/maintenance/remove", post(post_maintenance_remove))
.route("/api/maintenance/dedup/scan", post(post_dedup_scan))
.route("/api/maintenance/dedup/status", get(get_dedup_status))
.route("/api/maintenance/repair/:id", post(post_maintenance_repair))
.route("/api/cookies", get(get_cookies).post(post_cookies).delete(delete_cookies))
.route("/api/scheduler/run", post(post_scheduler_run))