//! Library health scanning and repair. //! //! Two kinds of problems are detected over the scanned library: //! //! * **Duplicates** — the same YouTube video ID stored more than once (either //! under different titles in one folder, or across folders). Each duplicate //! group lists every copy with a "score" so the UI can recommend keeping the //! most complete one and removing the rest. //! * **Missing assets** — a downloaded video lacking its thumbnail, `info.json`, //! or `.description` sidecar. These can be re-fetched from YouTube with yt-dlp //! (subtitles are fetched alongside, since their absence isn't a reliable //! signal — many videos legitimately have none). //! //! Deletion is never automatic: [`scan`] only reports, and [`remove_files`] //! refuses any path outside the library root. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use serde::Serialize; use crate::library::{Channel, Video}; /// One stored copy of a video (its on-disk files plus a completeness score). #[derive(Serialize, Clone)] pub struct DuplicateCopy { /// Human-readable directory the copy lives in, relative to the library root. pub location: String, /// Every file belonging to this copy (video + all sidecars). pub files: Vec, /// Size of the video file in bytes, if present. pub file_size: Option, /// Whether an actual video file (not just sidecars) is present. pub has_video: bool, /// True for the copy the UI recommends keeping (best score in the group). pub recommended_keep: bool, } /// A set of copies that all share one video ID. #[derive(Serialize, Clone)] pub struct DuplicateGroup { pub id: String, pub title: String, pub copies: Vec, } /// A video that is missing one or more sidecar assets. #[derive(Serialize, Clone)] pub struct MissingAssets { pub id: String, pub title: String, /// Directory containing the video, relative to the library root. pub location: String, pub missing_thumbnail: bool, pub missing_info: bool, pub missing_description: bool, } /// The full result of a [`scan`]. #[derive(Serialize, Clone, Default)] pub struct HealthReport { pub duplicates: Vec, pub missing: Vec, } /// The directory a video lives in, inferred from whichever path is known. fn video_dir(v: &Video) -> Option { v.video_path .as_ref() .or(v.info_path.as_ref()) .or(v.thumb_path.as_ref()) .or(v.description_path.as_ref()) .or(v.subtitles.first().map(|s| &s.path)) .and_then(|p| p.parent().map(Path::to_path_buf)) } /// Display a path relative to `root` (falls back to the full path). fn rel(root: &Path, p: &Path) -> String { p.strip_prefix(root).unwrap_or(p).display().to_string() } /// Every file on disk whose name begins with `.` in `dir`. /// /// This captures the video plus all sidecars (thumbnail, info.json, description, /// subtitles, live_chat.json, …) without listing each suffix explicitly. The /// trailing dot prevents matching a different video whose stem is a prefix. fn files_for_stem(dir: &Path, stem: &str) -> Vec { let prefix = format!("{stem}."); let mut out = Vec::new(); if let Ok(entries) = std::fs::read_dir(dir) { for e in entries.flatten() { if e.file_name().to_string_lossy().starts_with(&prefix) { out.push(e.path()); } } } out.sort(); out } /// Scan the library for duplicates and missing assets. pub fn scan(root: &Path, channels: &[Channel]) -> HealthReport { // Collect every video together with its source channel name. let mut all: Vec<&Video> = Vec::new(); for ch in channels { all.extend(ch.videos.iter()); for pl in &ch.playlists { all.extend(pl.videos.iter()); } } // ── Duplicates: group by video ID ────────────────────────────────────── let mut by_id: BTreeMap<&str, Vec<&Video>> = BTreeMap::new(); for v in &all { by_id.entry(v.id.as_str()).or_default().push(v); } let mut duplicates = Vec::new(); for (id, vids) in &by_id { if vids.len() < 2 { continue; } // Build a copy per video, then drop phantoms with no locatable files // (e.g. a stem known only by a leftover live_chat.json). let mut copies: Vec = vids .iter() .filter_map(|v| { let dir = video_dir(v)?; let files = files_for_stem(&dir, &v.stem); if files.is_empty() { return None; } Some(DuplicateCopy { location: rel(root, &dir), files, file_size: v.file_size, has_video: v.video_path.is_some(), recommended_keep: false, }) }) .collect(); if copies.len() < 2 { continue; // not a real duplicate once phantoms are removed } // Score each copy: a present video file dominates, then file size, // then the number of sidecar files. The highest score is kept. let best_idx = copies .iter() .enumerate() .max_by_key(|(_, c)| (c.has_video, c.file_size.unwrap_or(0), c.files.len())) .map(|(i, _)| i) .unwrap_or(0); copies[best_idx].recommended_keep = true; let title = vids .iter() .find(|v| v.video_path.is_some()) .unwrap_or(&vids[0]) .title .clone(); duplicates.push(DuplicateGroup { id: id.to_string(), title, copies, }); } // ── Missing assets: per video, only for ones with an actual video file ── // Dedup by ID so a duplicate group isn't reported many times here. let mut seen = std::collections::HashSet::new(); let mut missing = Vec::new(); for v in &all { if v.video_path.is_none() || !seen.insert(v.id.as_str()) { continue; } let missing_thumbnail = v.thumb_path.is_none(); let missing_info = v.info_path.is_none(); let missing_description = v.description_path.is_none(); if missing_thumbnail || missing_info || missing_description { missing.push(MissingAssets { id: v.id.clone(), title: v.title.clone(), location: video_dir(v).as_deref().map(|d| rel(root, d)).unwrap_or_default(), missing_thumbnail, missing_info, missing_description, }); } } HealthReport { duplicates, missing } } /// True if `target` resolves to a location inside `root`. /// /// `target` may not exist (e.g. the caller is about to delete it). In that /// case we canonicalise the parent directory and join the file name back on, /// so a deleted file still gets a correct safety verdict instead of a false /// "outside library" refusal. fn is_within(root: &Path, target: &Path) -> bool { let Ok(canon_root) = root.canonicalize() else { return false }; let canon_target = match target.canonicalize() { Ok(p) => p, Err(_) => { let Some(parent) = target.parent() else { return false }; let Some(name) = target.file_name() else { return false }; let Ok(canon_parent) = parent.canonicalize() else { return false }; canon_parent.join(name) } }; canon_target.starts_with(canon_root) } /// Delete the given files, refusing any path that escapes `root`. /// /// Returns the number of files removed and a list of human-readable errors /// (including refusals for out-of-bounds paths). pub fn remove_files(root: &Path, paths: &[PathBuf]) -> (usize, Vec) { let mut removed = 0; let mut errors = Vec::new(); for p in paths { if !is_within(root, p) { errors.push(format!("refused (outside library): {}", p.display())); continue; } match std::fs::remove_file(p) { Ok(()) => removed += 1, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // Already gone — treat as success so a duplicate-delete from // a stale UI doesn't look like an error. removed += 1; } Err(e) => errors.push(format!("{}: {e}", p.display())), } } (removed, errors) } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn is_within_accepts_inside_path() { let tmp = std::env::temp_dir().join("yt-offline-iw-test-1"); let _ = fs::create_dir_all(&tmp); let inside = tmp.join("foo.txt"); let _ = fs::write(&inside, "x"); assert!(is_within(&tmp, &inside)); let _ = fs::remove_file(&inside); let _ = fs::remove_dir(&tmp); } #[test] fn is_within_rejects_outside_path() { let tmp = std::env::temp_dir().join("yt-offline-iw-test-2"); let _ = fs::create_dir_all(&tmp); // The target's parent canonicalises to /tmp, which doesn't start with tmp. let outside = std::env::temp_dir().join("not-our-dir-xyz.txt"); assert!(!is_within(&tmp, &outside)); let _ = fs::remove_dir(&tmp); } #[test] fn is_within_handles_missing_target() { // Target doesn't exist; parent dir does and is inside root. let tmp = std::env::temp_dir().join("yt-offline-iw-test-3"); let _ = fs::create_dir_all(&tmp); let ghost = tmp.join("does-not-exist.txt"); assert!(is_within(&tmp, &ghost)); let _ = fs::remove_dir(&tmp); } } /// Look up a video's directory and filename stem by ID, for repair targeting. /// Returns `(dir, stem)` of the first matching copy with a known location. pub fn locate(channels: &[Channel], id: &str) -> Option<(PathBuf, String)> { for ch in channels { for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) { if v.id == id { if let Some(dir) = video_dir(v) { return Some((dir, v.stem.clone())); } } } } None }