//! Video library management: folder scanning, progress tracking, playlists, //! subtitle integration, and background duration scanning. use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::SystemTime; use crate::ffmpeg::{self, VideoMetadata}; use crate::state::{atomic_write_json, load_json_with_fallbacks, BACKUP_COUNT}; use crate::subtitles; use crate::utils::{ clamp, compute_library_id, file_fingerprint, is_within_root, natural_key, pretty_title_from_filename, }; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /// Supported video file extensions (lower-case, with leading dot). pub const VIDEO_EXTS: &[&str] = &[ ".mp4", ".m4v", ".mov", ".webm", ".mkv", ".avi", ".mpg", ".mpeg", ".m2ts", ".mts", ".ogv", ]; /// Current state file format version. const STATE_VERSION: u32 = 1; // --------------------------------------------------------------------------- // Structs // --------------------------------------------------------------------------- /// Reference to a stored subtitle file for a video. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SubtitleRef { pub vtt: String, pub label: String, } /// Per-video metadata: position, progress, notes, subtitle reference. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VideoMeta { pub pos: f64, pub watched: f64, pub duration: Option, pub finished: bool, pub note: String, pub last_open: u64, pub subtitle: Option, } impl Default for VideoMeta { fn default() -> Self { Self { pos: 0.0, watched: 0.0, duration: None, finished: false, note: String::new(), last_open: 0, subtitle: None, } } } /// Serializable library state persisted to disk. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LibraryState { pub version: u32, pub library_id: String, pub last_path: String, pub updated_at: u64, pub current_fid: Option, pub current_time: f64, pub volume: f64, pub autoplay: bool, pub playback_rate: f64, pub order_fids: Vec, pub videos: HashMap, } impl Default for LibraryState { fn default() -> Self { Self { version: STATE_VERSION, library_id: String::new(), last_path: String::new(), updated_at: 0, current_fid: None, current_time: 0.0, volume: 1.0, autoplay: false, playback_rate: 1.0, order_fids: Vec::new(), videos: HashMap::new(), } } } /// In-memory representation of the video library. pub struct Library { pub root: Option, pub files: Vec, pub fids: Vec, pub relpaths: Vec, pub rel_to_fid: HashMap, pub fid_to_rel: HashMap, pub state: LibraryState, pub state_path: Option, // ffmpeg paths pub ffprobe: Option, pub ffmpeg: Option, // metadata cache pub meta_cache: HashMap, // background scan control scan_stop: Arc, } // --------------------------------------------------------------------------- // Helper functions (private) // --------------------------------------------------------------------------- /// Return the current unix timestamp in seconds. fn now_secs() -> u64 { SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } /// Natural-sort relative paths and return the corresponding fid list. fn _sorted_default(relpaths: &[String], rel_to_fid: &HashMap) -> Vec { let mut sorted_rels: Vec = relpaths.to_vec(); sorted_rels.sort_by(|a, b| natural_key(a).cmp(&natural_key(b))); sorted_rels .iter() .filter_map(|r| rel_to_fid.get(r).cloned()) .collect() } /// Apply a saved ordering to the full set of file IDs. /// /// Files present in `order_fids` keep their saved order. Files not in the /// saved order are appended at the end, naturally sorted by their relative /// path. fn _apply_saved_order( all_fids: &[String], fid_to_rel: &HashMap, order_fids: &[String], ) -> Vec { if order_fids.is_empty() { // Fall back to natural sort. let mut fids = all_fids.to_vec(); fids.sort_by(|a, b| { let ra = fid_to_rel.get(a).cloned().unwrap_or_default(); let rb = fid_to_rel.get(b).cloned().unwrap_or_default(); natural_key(&ra).cmp(&natural_key(&rb)) }); return fids; } let all_set: std::collections::HashSet<&String> = all_fids.iter().collect(); // Saved order, keeping only fids that still exist. let mut result: Vec = order_fids .iter() .filter(|f| all_set.contains(f)) .cloned() .collect(); let ordered_set: std::collections::HashSet<&String> = result.iter().collect(); // New files not in the saved order, naturally sorted. let mut new_fids: Vec = all_fids .iter() .filter(|f| !ordered_set.contains(f)) .cloned() .collect(); new_fids.sort_by(|a, b| { let ra = fid_to_rel.get(a).cloned().unwrap_or_default(); let rb = fid_to_rel.get(b).cloned().unwrap_or_default(); natural_key(&ra).cmp(&natural_key(&rb)) }); result.extend(new_fids); result } /// Compute per-file tree display flags: depth, pipe characters, is_last, etc. /// /// Depth is limited to 1 level (immediate parent folder only). fn _tree_flags(rels: &[String]) -> HashMap { let mut result: HashMap = HashMap::new(); // Group files by their immediate parent folder. // For a relpath like "subfolder/file.mp4", parent = "subfolder". // For "file.mp4", parent = "". let mut groups: HashMap> = HashMap::new(); for (i, rel) in rels.iter().enumerate() { let parent = if let Some(pos) = rel.rfind('/') { rel[..pos].to_string() } else { String::new() }; groups.entry(parent).or_default().push(i); } // Track which parents we have seen so far for has_prev_in_parent. let mut parent_seen_count: HashMap = HashMap::new(); for (_i, rel) in rels.iter().enumerate() { let (parent, depth) = if let Some(pos) = rel.rfind('/') { (rel[..pos].to_string(), 1) } else { (String::new(), 0) }; let group = groups.get(&parent).unwrap(); let position_in_group = parent_seen_count.entry(parent.clone()).or_insert(0); let is_last = *position_in_group + 1 == group.len(); let has_prev_in_parent = *position_in_group > 0; *parent_seen_count.get_mut(&parent).unwrap() += 1; // Build pipe prefix for tree display. let pipes = if depth == 0 { String::new() } else { if is_last { "\u{2514}\u{2500} ".to_string() // "-- " } else { "\u{251C}\u{2500} ".to_string() // "|-- " } }; result.insert( rel.clone(), json!({ "depth": depth, "pipes": pipes, "is_last": is_last, "has_prev_in_parent": has_prev_in_parent, }), ); } result } /// Recursively scan a directory for video files. fn scan_video_files(dir: &Path) -> Vec { let mut results = Vec::new(); _scan_recursive(dir, &mut results); results } fn _scan_recursive(dir: &Path, out: &mut Vec) { let entries = match fs::read_dir(dir) { Ok(e) => e, Err(_) => return, }; let mut dirs: Vec = Vec::new(); let mut files: Vec = Vec::new(); for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { dirs.push(path); } else if path.is_file() { if let Some(ext) = path.extension() { let ext_lower = format!(".{}", ext.to_string_lossy().to_lowercase()); if VIDEO_EXTS.contains(&ext_lower.as_str()) { files.push(path); } } } } // Sort files naturally for deterministic ordering. files.sort_by(|a, b| { let na = a.file_name().unwrap_or_default().to_string_lossy(); let nb = b.file_name().unwrap_or_default().to_string_lossy(); natural_key(&na).cmp(&natural_key(&nb)) }); out.extend(files); // Recurse into subdirectories in sorted order. dirs.sort_by(|a, b| { let na = a.file_name().unwrap_or_default().to_string_lossy(); let nb = b.file_name().unwrap_or_default().to_string_lossy(); natural_key(&na).cmp(&natural_key(&nb)) }); for d in &dirs { _scan_recursive(d, out); } } // --------------------------------------------------------------------------- // Library implementation // --------------------------------------------------------------------------- impl Library { // ----------------------------------------------------------------------- // new // ----------------------------------------------------------------------- /// Create a new empty library with default state. pub fn new() -> Library { Library { root: None, files: Vec::new(), fids: Vec::new(), relpaths: Vec::new(), rel_to_fid: HashMap::new(), fid_to_rel: HashMap::new(), state: LibraryState::default(), state_path: None, ffprobe: None, ffmpeg: None, meta_cache: HashMap::new(), scan_stop: Arc::new(AtomicBool::new(false)), } } // ----------------------------------------------------------------------- // set_root // ----------------------------------------------------------------------- /// Open a folder as the video library root. /// /// Scans for video files, computes fingerprints, loads or creates state, /// merges with existing progress, and returns `get_library_info()`. pub fn set_root(&mut self, folder: &str, state_dir: &Path) -> Result { // 1. Resolve and verify folder. let folder_path = PathBuf::from(folder); let folder_path = folder_path .canonicalize() .map_err(|e| format!("Cannot resolve folder '{}': {}", folder, e))?; if !folder_path.is_dir() { return Err(format!("'{}' is not a directory", folder)); } // Signal any running background scan to stop. self.scan_stop.store(true, Ordering::SeqCst); // 2. Recursively scan for video files. let found_files = scan_video_files(&folder_path); if found_files.is_empty() { return Err(format!("No video files found in '{}'", folder)); } // 3. Compute fingerprints and build mappings. let mut rel_to_fid: HashMap = HashMap::new(); let mut fid_to_rel: HashMap = HashMap::new(); let mut all_fids: Vec = Vec::new(); let mut all_relpaths: Vec = Vec::new(); for file in &found_files { let relpath = file .strip_prefix(&folder_path) .unwrap_or(file.as_path()) .to_string_lossy() .replace('\\', "/"); let fid = file_fingerprint(file); // Skip duplicates (same fingerprint from hardlinks, etc.). if fid_to_rel.contains_key(&fid) { continue; } rel_to_fid.insert(relpath.clone(), fid.clone()); fid_to_rel.insert(fid.clone(), relpath.clone()); all_fids.push(fid); all_relpaths.push(relpath); } // 4. Compute library ID. let library_id = compute_library_id(&all_fids); // 5. Load existing state or create baseline. let state_file = state_dir.join(format!("library_{}.json", library_id)); let baseline = LibraryState { version: STATE_VERSION, library_id: library_id.clone(), last_path: folder_path.to_string_lossy().to_string(), updated_at: now_secs(), current_fid: None, current_time: 0.0, volume: 1.0, autoplay: false, playback_rate: 1.0, order_fids: Vec::new(), videos: HashMap::new(), }; let mut state = if let Some(loaded_val) = load_json_with_fallbacks(&state_file, BACKUP_COUNT) { // Try to deserialize the loaded JSON into LibraryState. if let Ok(loaded_state) = serde_json::from_value::(loaded_val.clone()) { // 6. Merge: only if same library_id. if loaded_state.library_id == library_id { self._merge_state(loaded_state, &baseline) } else { baseline } } else { baseline } } else { baseline }; // 7. Update last_path to current folder. state.last_path = folder_path.to_string_lossy().to_string(); // 8. Normalize video metadata for all found fids. let existing_videos = state.videos.clone(); let mut normalized: HashMap = HashMap::new(); for fid in &all_fids { let meta = existing_videos.get(fid).cloned().unwrap_or_default(); normalized.insert(fid.clone(), meta); } state.videos = normalized; // 9. Normalize settings. state.volume = clamp(state.volume, 0.0, 1.0); state.playback_rate = clamp(state.playback_rate, 0.25, 3.0); // 10. Clean order_fids. let valid_set: std::collections::HashSet<&String> = all_fids.iter().collect(); state.order_fids.retain(|f| valid_set.contains(f)); // 11. Apply saved ordering. let ordered_fids = _apply_saved_order(&all_fids, &fid_to_rel, &state.order_fids); state.order_fids = ordered_fids.clone(); // 12. Build ordered file lists. let ordered_relpaths: Vec = ordered_fids .iter() .filter_map(|fid| fid_to_rel.get(fid).cloned()) .collect(); let ordered_files: Vec = ordered_relpaths .iter() .map(|rel| folder_path.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))) .collect(); // 13. Validate current_fid. if let Some(ref cfid) = state.current_fid { if !fid_to_rel.contains_key(cfid) { state.current_fid = ordered_fids.first().cloned(); state.current_time = 0.0; } } // Assign everything to self. self.root = Some(folder_path); self.files = ordered_files; self.fids = ordered_fids; self.relpaths = ordered_relpaths; self.rel_to_fid = rel_to_fid; self.fid_to_rel = fid_to_rel; self.state = state; self.state_path = Some(state_file); self.meta_cache.clear(); self.scan_stop = Arc::new(AtomicBool::new(false)); // 14. Save state. self.save_state(); // 15. Return library info. Ok(self.get_library_info()) } /// Merge a loaded state with a baseline, preferring loaded values. fn _merge_state(&self, loaded: LibraryState, baseline: &LibraryState) -> LibraryState { LibraryState { version: STATE_VERSION, library_id: loaded.library_id, last_path: baseline.last_path.clone(), updated_at: loaded.updated_at, current_fid: loaded.current_fid, current_time: loaded.current_time, volume: loaded.volume, autoplay: loaded.autoplay, playback_rate: loaded.playback_rate, order_fids: loaded.order_fids, videos: loaded.videos, } } // ----------------------------------------------------------------------- // save_state // ----------------------------------------------------------------------- /// Persist the current state to disk with backup rotation. pub fn save_state(&mut self) { self.state.updated_at = now_secs(); if let Some(ref path) = self.state_path { if let Ok(val) = serde_json::to_value(&self.state) { atomic_write_json(path, &val, BACKUP_COUNT); } } } // ----------------------------------------------------------------------- // get_library_info // ----------------------------------------------------------------------- /// Build a comprehensive JSON object describing the library state. pub fn get_library_info(&self) -> Value { let folder = self .root .as_ref() .map(|p| { let s = p.to_string_lossy().to_string(); // Strip Windows extended-length path prefix. s.strip_prefix("\\\\?\\").unwrap_or(&s).to_string() }) .unwrap_or_default(); let count = self.fids.len(); let current_index = self .state .current_fid .as_ref() .and_then(|cfid| self.fids.iter().position(|f| f == cfid)); // Build tree flags. let tree_flags = _tree_flags(&self.relpaths); // Check if any subdirectories exist. let has_subdirs = self.relpaths.iter().any(|r| r.contains('/')); // Build items array. let mut items: Vec = Vec::new(); for (i, fid) in self.fids.iter().enumerate() { let relpath = self.fid_to_rel.get(fid).cloned().unwrap_or_default(); let filename = relpath.rsplit('/').next().unwrap_or(&relpath); let title = pretty_title_from_filename(filename); let flags = tree_flags.get(&relpath); let depth = flags .and_then(|f| f.get("depth")) .and_then(|v| v.as_u64()) .unwrap_or(0); let pipes = flags .and_then(|f| f.get("pipes")) .and_then(|v| v.as_str()) .unwrap_or(""); let is_last = flags .and_then(|f| f.get("is_last")) .and_then(|v| v.as_bool()) .unwrap_or(true); let has_prev_in_parent = flags .and_then(|f| f.get("has_prev_in_parent")) .and_then(|v| v.as_bool()) .unwrap_or(false); let meta = self.state.videos.get(fid); let pos = meta.map(|m| m.pos).unwrap_or(0.0); let watched = meta.map(|m| m.watched).unwrap_or(0.0); let duration = meta.and_then(|m| m.duration); let finished = meta.map(|m| m.finished).unwrap_or(false); let note_len = meta.map(|m| m.note.len()).unwrap_or(0); let last_open = meta.map(|m| m.last_open).unwrap_or(0); let has_sub = meta .map(|m| m.subtitle.is_some()) .unwrap_or(false); items.push(json!({ "index": i, "fid": fid, "name": filename, "title": title, "relpath": relpath, "depth": depth, "pipes": pipes, "is_last": is_last, "has_prev_in_parent": has_prev_in_parent, "pos": pos, "watched": watched, "duration": duration, "finished": finished, "note_len": note_len, "last_open": last_open, "has_sub": has_sub, })); } // Folder stats. let stats = self._folder_stats(); // Next up: first unfinished video after current. let next_up = self._compute_next_up(current_index); json!({ "ok": true, "folder": folder, "library_id": self.state.library_id, "count": count, "current_index": current_index, "current_fid": self.state.current_fid, "current_time": self.state.current_time, "folder_volume": self.state.volume, "folder_autoplay": self.state.autoplay, "folder_rate": self.state.playback_rate, "items": items, "has_subdirs": has_subdirs, "overall_progress": stats.get("overall_progress").cloned().unwrap_or(json!(0.0)), "durations_known": stats.get("durations_known").cloned().unwrap_or(json!(false)), "finished_count": stats.get("finished_count").cloned().unwrap_or(json!(0)), "remaining_count": stats.get("remaining_count").cloned().unwrap_or(json!(0)), "remaining_seconds_known": stats.get("remaining_seconds_known").cloned().unwrap_or(json!(0.0)), "top_folders": stats.get("top_folders").cloned().unwrap_or(json!([])), "next_up": next_up, }) } /// Find the next unfinished video index after the current position. fn _compute_next_up(&self, current_index: Option) -> Value { let make_result = |i: usize| -> Value { let fid = &self.fids[i]; let title = self.fid_to_rel.get(fid) .map(|r| pretty_title_from_filename(r)) .unwrap_or_default(); json!({"index": i, "title": title}) }; let start = current_index.map(|i| i + 1).unwrap_or(0); for i in start..self.fids.len() { let fid = &self.fids[i]; let finished = self .state .videos .get(fid) .map(|m| m.finished) .unwrap_or(false); if !finished { return make_result(i); } } // Wrap around from beginning. let end = current_index.unwrap_or(self.fids.len()); for i in 0..end.min(self.fids.len()) { let fid = &self.fids[i]; let finished = self .state .videos .get(fid) .map(|m| m.finished) .unwrap_or(false); if !finished { return make_result(i); } } Value::Null } // ----------------------------------------------------------------------- // update_progress // ----------------------------------------------------------------------- /// Update playback progress for a video at the given index. /// /// The `finished` flag is sticky: once set, it remains true even if /// `current_time` decreases. pub fn update_progress( &mut self, index: usize, current_time: f64, duration: Option, playing: bool, ) -> Value { if self.fids.is_empty() { return json!({"ok": false, "error": "library is empty"}); } let index = index.min(self.fids.len() - 1); let fid = self.fids[index].clone(); let meta = self .state .videos .entry(fid.clone()) .or_insert_with(VideoMeta::default); meta.pos = current_time; // High-water mark. if current_time > meta.watched { meta.watched = current_time; } // Update duration if provided and positive. if let Some(d) = duration { if d > 0.0 { meta.duration = Some(d); } } // Finished is sticky. let effective_duration = meta.duration.unwrap_or(0.0); let threshold = (effective_duration - 2.0).max(0.0); if effective_duration > 0.0 && current_time >= threshold { meta.finished = true; } // Note: if already finished, it stays finished. // Update last_open timestamp if actively playing. if playing { meta.last_open = now_secs(); } // Update current tracking. self.state.current_fid = Some(fid); self.state.current_time = current_time; // Snapshot values before releasing the borrow on self.state.videos. let pos = meta.pos; let watched = meta.watched; let duration_val = meta.duration; let finished = meta.finished; self.save_state(); json!({ "ok": true, "index": index, "pos": pos, "watched": watched, "duration": duration_val, "finished": finished, }) } // ----------------------------------------------------------------------- // set_current // ----------------------------------------------------------------------- /// Set the currently active video by index and timecode. pub fn set_current(&mut self, index: usize, timecode: f64) -> Value { if self.fids.is_empty() { return json!({"ok": false, "error": "library is empty"}); } let index = index.min(self.fids.len() - 1); let fid = self.fids[index].clone(); self.state.current_fid = Some(fid.clone()); self.state.current_time = timecode; self.save_state(); json!({ "ok": true, "index": index, "fid": fid, "current_time": timecode, }) } // ----------------------------------------------------------------------- // set_folder_volume // ----------------------------------------------------------------------- /// Set the folder-level volume (clamped 0.0 to 1.0). pub fn set_folder_volume(&mut self, volume: f64) -> Value { self.state.volume = clamp(volume, 0.0, 1.0); self.save_state(); json!({"ok": true, "folder_volume": self.state.volume}) } // ----------------------------------------------------------------------- // set_folder_autoplay // ----------------------------------------------------------------------- /// Set the folder-level autoplay preference. pub fn set_folder_autoplay(&mut self, enabled: bool) -> Value { self.state.autoplay = enabled; self.save_state(); json!({"ok": true, "folder_autoplay": self.state.autoplay}) } // ----------------------------------------------------------------------- // set_folder_rate // ----------------------------------------------------------------------- /// Set the folder-level playback rate (clamped 0.25 to 3.0). pub fn set_folder_rate(&mut self, rate: f64) -> Value { self.state.playback_rate = clamp(rate, 0.25, 3.0); self.save_state(); json!({"ok": true, "folder_rate": self.state.playback_rate}) } // ----------------------------------------------------------------------- // set_order // ----------------------------------------------------------------------- /// Reorder the playlist. `fids` contains the desired ordering; any fids /// not in the list are appended at the end (naturally sorted). pub fn set_order(&mut self, fids: Vec) -> Value { let valid_set: std::collections::HashSet<&String> = self.fids.iter().collect(); // Keep only fids that actually exist. let mut ordered: Vec = fids.into_iter().filter(|f| valid_set.contains(f)).collect(); // Append remaining fids not in the new order. let ordered_set: std::collections::HashSet<&String> = ordered.iter().collect(); let mut remaining: Vec = self .fids .iter() .filter(|f| !ordered_set.contains(f)) .cloned() .collect(); remaining.sort_by(|a, b| { let ra = self.fid_to_rel.get(a).cloned().unwrap_or_default(); let rb = self.fid_to_rel.get(b).cloned().unwrap_or_default(); natural_key(&ra).cmp(&natural_key(&rb)) }); ordered.extend(remaining); // Rebuild file lists. self.fids = ordered.clone(); self.relpaths = ordered .iter() .filter_map(|fid| self.fid_to_rel.get(fid).cloned()) .collect(); if let Some(ref root) = self.root { self.files = self .relpaths .iter() .map(|rel| root.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))) .collect(); } self.state.order_fids = ordered; self.save_state(); self.get_library_info() } // ----------------------------------------------------------------------- // get_note / set_note // ----------------------------------------------------------------------- /// Return the note for the given fid, or an empty string if not found. pub fn get_note(&self, fid: &str) -> String { self.state .videos .get(fid) .map(|m| m.note.clone()) .unwrap_or_default() } /// Set a note for the given fid and save state. pub fn set_note(&mut self, fid: &str, note: &str) -> Value { if let Some(meta) = self.state.videos.get_mut(fid) { meta.note = note.to_string(); } else { let mut m = VideoMeta::default(); m.note = note.to_string(); self.state.videos.insert(fid.to_string(), m); } self.save_state(); json!({"ok": true, "fid": fid, "note_len": note.len()}) } // ----------------------------------------------------------------------- // get_video_path // ----------------------------------------------------------------------- /// Get the full file path for a video by index. /// /// Performs bounds checking and path-traversal protection. pub fn get_video_path(&self, index: usize) -> Result { if self.files.is_empty() { return Err("No files in library".to_string()); } if index >= self.files.len() { return Err(format!( "Index {} out of range (0..{})", index, self.files.len() )); } let path = &self.files[index]; // Path-traversal protection. if let Some(ref root) = self.root { if !is_within_root(root, path) { return Err("Path traversal detected".to_string()); } } if !path.exists() { return Err(format!("File not found: {}", path.display())); } Ok(path.clone()) } // ----------------------------------------------------------------------- // get_current_video_metadata // ----------------------------------------------------------------------- /// Return basic file metadata plus cached ffprobe metadata for the /// current video. pub fn get_current_video_metadata(&mut self) -> Value { let fid = match &self.state.current_fid { Some(f) => f.clone(), None => return json!({"ok": false, "error": "no current video"}), }; let index = match self.fids.iter().position(|f| f == &fid) { Some(i) => i, None => return json!({"ok": false, "error": "current fid not in library"}), }; let basic = self._basic_file_meta(&fid); // Probe if not cached. if !self.meta_cache.contains_key(&fid) { if let Some(ref ffprobe) = self.ffprobe.clone() { if index < self.files.len() { if let Some(meta) = ffmpeg::ffprobe_video_metadata(&self.files[index], ffprobe) { self.meta_cache.insert(fid.clone(), meta); } } } } let mut result = json!({ "ok": true, "basic": basic, "ffprobe_found": self.ffprobe.is_some(), }); if let Some(cached) = self.meta_cache.get(&fid) { if let Ok(meta_val) = serde_json::to_value(cached) { result .as_object_mut() .unwrap() .insert("probe".to_string(), meta_val); } } result } // ----------------------------------------------------------------------- // Subtitle methods // ----------------------------------------------------------------------- /// Build a protocol URL for a stored subtitle VTT path. /// The `vtt` field is like `"subtitles/{fid}_{name}.vtt"` — extract the filename. fn _sub_url(vtt: &str) -> String { let filename = vtt.rsplit('/').next().unwrap_or(vtt); format!("http://tutdock.localhost/sub/{}", filename) } /// Build a successful subtitle JSON response with `has`, `url`, and `label`. fn _sub_response(vtt: &str, label: &str) -> Value { json!({ "ok": true, "has": true, "url": Self::_sub_url(vtt), "label": label, }) } /// Get subtitle for the current video. /// /// Priority: stored subtitle -> sidecar -> embedded. pub fn get_subtitle_for_current(&mut self, state_dir: &Path) -> Value { let fid = match &self.state.current_fid { Some(f) => f.clone(), None => return json!({"ok": false, "error": "no current video"}), }; let index = match self.fids.iter().position(|f| f == &fid) { Some(i) => i, None => return json!({"ok": false, "error": "current fid not in library"}), }; // 1. Check stored subtitle. if let Some(meta) = self.state.videos.get(&fid) { if let Some(ref sub_ref) = meta.subtitle { let vtt_path = state_dir.join(&sub_ref.vtt); if vtt_path.exists() { return Self::_sub_response(&sub_ref.vtt, &sub_ref.label); } } } // 2. Try sidecar. if index < self.files.len() { if let Some(sidecar_path) = subtitles::auto_subtitle_sidecar(&self.files[index]) { let subs_dir = state_dir.join("subtitles"); if let Some(stored) = subtitles::store_subtitle_for_fid(&fid, &sidecar_path, &subs_dir) { // Save reference. if let Some(meta) = self.state.videos.get_mut(&fid) { meta.subtitle = Some(SubtitleRef { vtt: stored.vtt.clone(), label: stored.label.clone(), }); } self.save_state(); return Self::_sub_response(&stored.vtt, &stored.label); } } } // 3. Try embedded (first subtitle track). if let Some(ref ffmpeg_path) = self.ffmpeg.clone() { if let Some(ref ffprobe_path) = self.ffprobe.clone() { if index < self.files.len() { if let Some(meta) = ffmpeg::ffprobe_video_metadata(&self.files[index], ffprobe_path) { if let Some(track) = meta.subtitle_tracks.first() { let subs_dir = state_dir.join("subtitles"); if let Ok(stored) = subtitles::extract_embedded_subtitle( &self.files[index], track.index, ffmpeg_path, &subs_dir, &fid, ) { if let Some(vmeta) = self.state.videos.get_mut(&fid) { vmeta.subtitle = Some(SubtitleRef { vtt: stored.vtt.clone(), label: stored.label.clone(), }); } self.save_state(); return Self::_sub_response(&stored.vtt, &stored.label); } } } } } } json!({"ok": true, "has": false}) } /// Store a user-selected subtitle file for the current video. pub fn set_subtitle_for_current(&mut self, file_path: &str, state_dir: &Path) -> Value { let fid = match &self.state.current_fid { Some(f) => f.clone(), None => return json!({"ok": false, "error": "no current video"}), }; let src = PathBuf::from(file_path); if !src.is_file() { return json!({"ok": false, "error": "subtitle file not found"}); } let subs_dir = state_dir.join("subtitles"); match subtitles::store_subtitle_for_fid(&fid, &src, &subs_dir) { Some(stored) => { if let Some(meta) = self.state.videos.get_mut(&fid) { meta.subtitle = Some(SubtitleRef { vtt: stored.vtt.clone(), label: stored.label.clone(), }); } self.save_state(); Self::_sub_response(&stored.vtt, &stored.label) } None => { json!({"ok": false, "error": "unsupported subtitle format"}) } } } /// Get list of embedded subtitle tracks using ffprobe. pub fn get_embedded_subtitles(&self) -> Value { let fid = match &self.state.current_fid { Some(f) => f.clone(), None => return json!({"ok": false, "error": "no current video"}), }; let index = match self.fids.iter().position(|f| f == &fid) { Some(i) => i, None => return json!({"ok": false, "error": "current fid not in library"}), }; if let Some(ref ffprobe_path) = self.ffprobe { if index < self.files.len() { if let Some(meta) = ffmpeg::ffprobe_video_metadata(&self.files[index], ffprobe_path) { let tracks: Vec = meta .subtitle_tracks .iter() .map(|t| { json!({ "index": t.index, "codec": t.codec, "language": t.language, "title": t.title, }) }) .collect(); return json!({"ok": true, "tracks": tracks}); } } } json!({"ok": true, "tracks": []}) } /// Extract an embedded subtitle track by index. pub fn extract_embedded_subtitle( &mut self, track_index: u32, state_dir: &Path, ) -> Value { let fid = match &self.state.current_fid { Some(f) => f.clone(), None => return json!({"ok": false, "error": "no current video"}), }; let index = match self.fids.iter().position(|f| f == &fid) { Some(i) => i, None => return json!({"ok": false, "error": "current fid not in library"}), }; let ffmpeg_path = match &self.ffmpeg { Some(p) => p.clone(), None => return json!({"ok": false, "error": "ffmpeg not available"}), }; if index >= self.files.len() { return json!({"ok": false, "error": "index out of range"}); } let subs_dir = state_dir.join("subtitles"); match subtitles::extract_embedded_subtitle( &self.files[index], track_index, &ffmpeg_path, &subs_dir, &fid, ) { Ok(stored) => { if let Some(meta) = self.state.videos.get_mut(&fid) { meta.subtitle = Some(SubtitleRef { vtt: stored.vtt.clone(), label: stored.label.clone(), }); } self.save_state(); Self::_sub_response(&stored.vtt, &stored.label) } Err(e) => { json!({"ok": false, "error": e}) } } } /// Get all available sidecar and embedded subtitles for the current video. /// /// Sidecar discovery uses normalized matching: /// - Check all .srt/.vtt files in video's parent directory /// - Match by: exact stem, normalized stem, stem with language suffix /// - Extract language labels from filename suffixes /// - Sort: English first, then alphabetical /// - Deduplicate by label pub fn get_available_subtitles(&self) -> Value { let fid = match &self.state.current_fid { Some(f) => f.clone(), None => return json!({"ok": false, "error": "no current video"}), }; let index = match self.fids.iter().position(|f| f == &fid) { Some(i) => i, None => return json!({"ok": false, "error": "current fid not in library"}), }; let mut sidecar_subs: Vec = Vec::new(); if index < self.files.len() { let video_path = &self.files[index]; if let Some(parent) = video_path.parent() { let video_stem = video_path .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); let video_stem_lower = video_stem.to_lowercase(); let video_stem_norm = _normalize_stem(&video_stem); if let Ok(entries) = fs::read_dir(parent) { let mut found: Vec<(String, String, bool)> = Vec::new(); // (label, path, is_english) let mut seen_labels: std::collections::HashSet = std::collections::HashSet::new(); for entry in entries.flatten() { let path = entry.path(); if !path.is_file() { continue; } let fname = match path.file_name() { Some(n) => n.to_string_lossy().to_string(), None => continue, }; let fname_lower = fname.to_lowercase(); let is_sub = subtitles::SUB_EXTS .iter() .any(|ext| fname_lower.ends_with(ext)); if !is_sub { continue; } let sub_stem = match path.file_stem() { Some(s) => s.to_string_lossy().to_string(), None => continue, }; let sub_stem_lower = sub_stem.to_lowercase(); let sub_stem_norm = _normalize_stem(&sub_stem); // Check if this subtitle matches the video. let mut matched = false; let mut lang_label = String::new(); let mut is_english = false; // Exact stem match. if sub_stem_lower == video_stem_lower { matched = true; lang_label = "Default".to_string(); } // Normalized match. if !matched && sub_stem_norm == video_stem_norm { matched = true; lang_label = "Default".to_string(); } // Language suffix match. if !matched { if let Some(dot_pos) = sub_stem.rfind('.') { let base = &sub_stem[..dot_pos]; let suffix = &sub_stem[dot_pos + 1..]; let base_lower = base.to_lowercase(); let base_norm = _normalize_stem(base); let suffix_lower = suffix.to_lowercase(); if base_lower == video_stem_lower || base_norm == video_stem_norm { matched = true; lang_label = _language_label(&suffix_lower); is_english = ["en", "eng", "english"] .contains(&suffix_lower.as_str()); } } } if matched { if seen_labels.insert(lang_label.clone()) { found.push(( lang_label, path.to_string_lossy().to_string(), is_english, )); } } } // Sort: English first, then alphabetical. found.sort_by(|a, b| { if a.2 && !b.2 { std::cmp::Ordering::Less } else if !a.2 && b.2 { std::cmp::Ordering::Greater } else { a.0.cmp(&b.0) } }); for (label, path, _) in &found { let ext = std::path::Path::new(path) .extension() .map(|e| e.to_string_lossy().to_uppercase()) .unwrap_or_default(); sidecar_subs.push(json!({ "type": "sidecar", "label": label, "path": path, "format": ext, })); } } } } // Embedded subtitles. let mut embedded_subs: Vec = Vec::new(); if let Some(ref ffprobe_path) = self.ffprobe { if index < self.files.len() { if let Some(meta) = ffmpeg::ffprobe_video_metadata(&self.files[index], ffprobe_path) { for track in &meta.subtitle_tracks { let label = if !track.title.is_empty() { track.title.clone() } else if !track.language.is_empty() { _language_label(&track.language) } else { format!("Track {}", track.index) }; embedded_subs.push(json!({ "type": "embedded", "label": label, "index": track.index, "codec": track.codec, "language": track.language, })); } } } } json!({ "ok": true, "sidecar": sidecar_subs, "embedded": embedded_subs, }) } /// Load a specific sidecar subtitle file for the current video. pub fn load_sidecar_subtitle( &mut self, file_path: &str, state_dir: &Path, ) -> Value { let fid = match &self.state.current_fid { Some(f) => f.clone(), None => return json!({"ok": false, "error": "no current video"}), }; let src = PathBuf::from(file_path); if !src.is_file() { return json!({"ok": false, "error": "subtitle file not found"}); } let subs_dir = state_dir.join("subtitles"); match subtitles::store_subtitle_for_fid(&fid, &src, &subs_dir) { Some(stored) => { if let Some(meta) = self.state.videos.get_mut(&fid) { meta.subtitle = Some(SubtitleRef { vtt: stored.vtt.clone(), label: stored.label.clone(), }); } self.save_state(); Self::_sub_response(&stored.vtt, &stored.label) } None => { json!({"ok": false, "error": "unsupported subtitle format or read error"}) } } } // ----------------------------------------------------------------------- // reset_watch_progress // ----------------------------------------------------------------------- /// Reset pos, watched, and finished for ALL videos. /// /// Preserves notes, durations, and subtitle references. pub fn reset_watch_progress(&mut self) -> Value { for meta in self.state.videos.values_mut() { meta.pos = 0.0; meta.watched = 0.0; meta.finished = false; } self.state.current_time = 0.0; self.save_state(); json!({"ok": true}) } // ----------------------------------------------------------------------- // Duration scanning // ----------------------------------------------------------------------- /// Return a list of (fid, file_path) pairs where duration is unknown. pub fn get_pending_scans(&self) -> Vec<(String, PathBuf)> { let mut pending = Vec::new(); for (i, fid) in self.fids.iter().enumerate() { let has_duration = self .state .videos .get(fid) .and_then(|m| m.duration) .is_some(); if !has_duration && i < self.files.len() { pending.push((fid.clone(), self.files[i].clone())); } } pending } /// Apply a scanned duration to a video's metadata. /// /// Also re-checks the finished status: if watched >= duration - 2, /// mark finished. pub fn apply_scanned_duration(&mut self, fid: &str, duration: f64) { if duration <= 0.0 { return; } let meta = self .state .videos .entry(fid.to_string()) .or_insert_with(VideoMeta::default); meta.duration = Some(duration); // Re-check finished status with the new duration. let threshold = (duration - 2.0).max(0.0); if meta.watched >= threshold { meta.finished = true; } self.save_state(); } /// Start a background duration scan. /// /// This method signals any previous scan to stop, then prepares the /// pending list. The actual scanning is driven externally via /// `get_pending_scans()` and `apply_scanned_duration()`. pub fn start_duration_scan(&mut self) { // Signal any running scan to stop. self.scan_stop.store(true, Ordering::SeqCst); // Create a fresh stop flag for any new scan cycle. self.scan_stop = Arc::new(AtomicBool::new(false)); } /// Check whether the background scan has been signalled to stop. pub fn is_scan_stopped(&self) -> bool { self.scan_stop.load(Ordering::SeqCst) } /// Get a reference to the scan stop flag for external use. pub fn scan_stop_flag(&self) -> Arc { Arc::clone(&self.scan_stop) } // ----------------------------------------------------------------------- // Private helpers // ----------------------------------------------------------------------- /// Compute folder statistics: finished/remaining counts, progress, etc. fn _folder_stats(&self) -> Value { let total = self.fids.len(); let mut finished_count: usize = 0; let mut total_duration: f64 = 0.0; let mut total_watched: f64 = 0.0; let mut all_durations_known = true; let mut remaining_seconds: f64 = 0.0; // Track top-level folder progress. let mut folder_totals: HashMap = HashMap::new(); // (total, finished) for fid in &self.fids { let meta = self.state.videos.get(fid); let finished = meta.map(|m| m.finished).unwrap_or(false); let duration = meta.and_then(|m| m.duration); let watched = meta.map(|m| m.watched).unwrap_or(0.0); if finished { finished_count += 1; } if let Some(d) = duration { total_duration += d; total_watched += watched.min(d); if !finished { remaining_seconds += (d - watched).max(0.0); } } else { all_durations_known = false; } // Top-level folder grouping. let relpath = self.fid_to_rel.get(fid).cloned().unwrap_or_default(); let top_folder = if let Some(pos) = relpath.find('/') { relpath[..pos].to_string() } else { String::new() }; let entry = folder_totals.entry(top_folder).or_insert((0, 0)); entry.0 += 1; if finished { entry.1 += 1; } } let overall_progress = if total > 0 { if total_duration > 0.0 { (total_watched / total_duration * 100.0).min(100.0) } else { (finished_count as f64 / total as f64) * 100.0 } } else { 0.0 }; let remaining_count = total - finished_count; // Build top_folders list (only if there are subdirs). let mut top_folders: Vec = Vec::new(); let mut folder_names: Vec = folder_totals.keys().cloned().collect(); folder_names.sort_by(|a, b| natural_key(a).cmp(&natural_key(b))); for name in &folder_names { if name.is_empty() { continue; // Skip root-level files. } let (ft, ff) = folder_totals[name]; top_folders.push(json!({ "name": name, "total": ft, "finished": ff, })); } json!({ "finished_count": finished_count, "remaining_count": remaining_count, "remaining_seconds_known": remaining_seconds, "overall_progress": overall_progress, "durations_known": all_durations_known, "top_folders": top_folders, }) } /// Get basic file metadata (extension, size, mtime, folder). fn _basic_file_meta(&self, fid: &str) -> Value { let relpath = self.fid_to_rel.get(fid).cloned().unwrap_or_default(); let index = self.fids.iter().position(|f| f == fid); if let Some(i) = index { if i < self.files.len() { let path = &self.files[i]; let ext = path .extension() .map(|e| e.to_string_lossy().to_string()) .unwrap_or_default(); let (size, mtime) = match fs::metadata(path) { Ok(m) => { let size = m.len(); let mtime = m .modified() .ok() .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); (size, mtime) } Err(_) => (0, 0), }; let folder = path .parent() .map(|p| { let s = p.to_string_lossy().to_string(); s.strip_prefix("\\\\?\\").unwrap_or(&s).to_string() }) .unwrap_or_default(); return json!({ "fid": fid, "relpath": relpath, "ext": ext, "size": size, "mtime": mtime, "folder": folder, }); } } json!({ "fid": fid, "relpath": relpath, "ext": "", "size": 0, "mtime": 0, "folder": "", }) } } // --------------------------------------------------------------------------- // Module-level helpers // --------------------------------------------------------------------------- /// Normalize a stem for fuzzy matching (lowercase, collapse separators). fn _normalize_stem(s: &str) -> String { let lower = s.to_lowercase(); let re = regex::Regex::new(r"[-_\s]+").unwrap(); let replaced = re.replace_all(&lower, " "); replaced.trim().to_string() } /// Convert a language suffix to a human-readable label. fn _language_label(lang: &str) -> String { match lang.to_lowercase().as_str() { "en" | "eng" | "english" => "English".to_string(), "fr" | "fre" | "fra" | "french" => "French".to_string(), "de" | "deu" | "ger" | "german" => "German".to_string(), "es" | "spa" | "spanish" => "Spanish".to_string(), "it" | "ita" | "italian" => "Italian".to_string(), "pt" | "por" | "portuguese" => "Portuguese".to_string(), "ru" | "rus" | "russian" => "Russian".to_string(), "ja" | "jpn" | "japanese" => "Japanese".to_string(), "ko" | "kor" | "korean" => "Korean".to_string(), "zh" | "zho" | "chi" | "chinese" => "Chinese".to_string(), "ar" | "ara" | "arabic" => "Arabic".to_string(), "hi" | "hin" | "hindi" => "Hindi".to_string(), "nl" | "dut" | "nld" | "dutch" => "Dutch".to_string(), "sv" | "swe" | "swedish" => "Swedish".to_string(), "pl" | "pol" | "polish" => "Polish".to_string(), other => { // Capitalize first letter. let mut c = other.chars(); match c.next() { Some(first) => { let upper: String = first.to_uppercase().collect(); format!("{}{}", upper, c.as_str()) } None => String::new(), } } } } // =========================================================================== // Tests // =========================================================================== #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; // ----------------------------------------------------------------------- // 1. test_new_library_is_empty // ----------------------------------------------------------------------- #[test] fn test_new_library_is_empty() { let lib = Library::new(); assert!(lib.root.is_none()); assert!(lib.files.is_empty()); assert!(lib.fids.is_empty()); assert!(lib.relpaths.is_empty()); assert!(lib.rel_to_fid.is_empty()); assert!(lib.fid_to_rel.is_empty()); assert!(lib.state_path.is_none()); assert!(lib.state.current_fid.is_none()); assert_eq!(lib.state.library_id, ""); assert_eq!(lib.state.volume, 1.0); assert_eq!(lib.state.playback_rate, 1.0); assert!(!lib.state.autoplay); } // ----------------------------------------------------------------------- // 2. test_sorted_default // ----------------------------------------------------------------------- #[test] fn test_sorted_default() { let relpaths = vec![ "video10.mp4".to_string(), "video2.mp4".to_string(), "video1.mp4".to_string(), "video20.mp4".to_string(), ]; let mut rel_to_fid = HashMap::new(); for (i, rel) in relpaths.iter().enumerate() { rel_to_fid.insert(rel.clone(), format!("fid{}", i)); } let sorted = _sorted_default(&relpaths, &rel_to_fid); let sorted_rels: Vec = sorted .iter() .map(|fid| { rel_to_fid .iter() .find(|(_, v)| *v == fid) .map(|(k, _)| k.clone()) .unwrap() }) .collect(); assert_eq!( sorted_rels, vec![ "video1.mp4".to_string(), "video2.mp4".to_string(), "video10.mp4".to_string(), "video20.mp4".to_string(), ] ); } // ----------------------------------------------------------------------- // 3. test_apply_saved_order_with_valid_order // ----------------------------------------------------------------------- #[test] fn test_apply_saved_order_with_valid_order() { let all_fids = vec!["a".to_string(), "b".to_string(), "c".to_string()]; let mut fid_to_rel = HashMap::new(); fid_to_rel.insert("a".to_string(), "01.mp4".to_string()); fid_to_rel.insert("b".to_string(), "02.mp4".to_string()); fid_to_rel.insert("c".to_string(), "03.mp4".to_string()); let order = vec!["c".to_string(), "a".to_string(), "b".to_string()]; let result = _apply_saved_order(&all_fids, &fid_to_rel, &order); assert_eq!(result, vec!["c", "a", "b"]); } // ----------------------------------------------------------------------- // 4. test_apply_saved_order_with_new_files // ----------------------------------------------------------------------- #[test] fn test_apply_saved_order_with_new_files() { let all_fids = vec![ "a".to_string(), "b".to_string(), "c".to_string(), "d".to_string(), ]; let mut fid_to_rel = HashMap::new(); fid_to_rel.insert("a".to_string(), "01.mp4".to_string()); fid_to_rel.insert("b".to_string(), "02.mp4".to_string()); fid_to_rel.insert("c".to_string(), "03.mp4".to_string()); fid_to_rel.insert("d".to_string(), "04.mp4".to_string()); // Saved order only has a and c; b and d are "new". let order = vec!["c".to_string(), "a".to_string()]; let result = _apply_saved_order(&all_fids, &fid_to_rel, &order); // c, a come first (saved order), then b, d (naturally sorted). assert_eq!(result, vec!["c", "a", "b", "d"]); } // ----------------------------------------------------------------------- // 5. test_apply_saved_order_empty // ----------------------------------------------------------------------- #[test] fn test_apply_saved_order_empty() { let all_fids = vec!["b".to_string(), "a".to_string(), "c".to_string()]; let mut fid_to_rel = HashMap::new(); fid_to_rel.insert("a".to_string(), "01_alpha.mp4".to_string()); fid_to_rel.insert("b".to_string(), "02_beta.mp4".to_string()); fid_to_rel.insert("c".to_string(), "03_gamma.mp4".to_string()); let result = _apply_saved_order(&all_fids, &fid_to_rel, &[]); // Falls back to natural sort by relpath. assert_eq!(result, vec!["a", "b", "c"]); } // ----------------------------------------------------------------------- // 6. test_tree_flags_flat // ----------------------------------------------------------------------- #[test] fn test_tree_flags_flat() { let rels = vec![ "video1.mp4".to_string(), "video2.mp4".to_string(), "video3.mp4".to_string(), ]; let flags = _tree_flags(&rels); for rel in &rels { let f = flags.get(rel).unwrap(); assert_eq!(f["depth"], 0); assert_eq!(f["pipes"], ""); } // Last file should have is_last = true. assert_eq!(flags["video3.mp4"]["is_last"], true); // First file should not have has_prev_in_parent. assert_eq!(flags["video1.mp4"]["has_prev_in_parent"], false); // Second file should have has_prev_in_parent. assert_eq!(flags["video2.mp4"]["has_prev_in_parent"], true); } // ----------------------------------------------------------------------- // 7. test_tree_flags_nested // ----------------------------------------------------------------------- #[test] fn test_tree_flags_nested() { let rels = vec![ "intro.mp4".to_string(), "chapter1/lesson1.mp4".to_string(), "chapter1/lesson2.mp4".to_string(), "chapter2/lesson1.mp4".to_string(), ]; let flags = _tree_flags(&rels); // Root-level file. assert_eq!(flags["intro.mp4"]["depth"], 0); // Nested files. assert_eq!(flags["chapter1/lesson1.mp4"]["depth"], 1); assert_eq!(flags["chapter1/lesson2.mp4"]["depth"], 1); assert_eq!(flags["chapter2/lesson1.mp4"]["depth"], 1); // chapter1/lesson1 should not have prev in parent. assert_eq!(flags["chapter1/lesson1.mp4"]["has_prev_in_parent"], false); // chapter1/lesson2 should have prev in parent. assert_eq!(flags["chapter1/lesson2.mp4"]["has_prev_in_parent"], true); // chapter1/lesson2 is the last in chapter1. assert_eq!(flags["chapter1/lesson2.mp4"]["is_last"], true); // chapter2/lesson1 is the only (and last) in chapter2. assert_eq!(flags["chapter2/lesson1.mp4"]["is_last"], true); } // ----------------------------------------------------------------------- // 8. test_set_root_with_video_files // ----------------------------------------------------------------------- #[test] fn test_set_root_with_video_files() { let dir = TempDir::new().unwrap(); let state_dir = TempDir::new().unwrap(); // Create dummy video files. fs::write(dir.path().join("video_a.mp4"), b"fake video a content").unwrap(); fs::write(dir.path().join("video_b.mp4"), b"fake video b content").unwrap(); fs::write(dir.path().join("video_c.mp4"), b"fake video c content").unwrap(); // Also create a non-video file (should be ignored). fs::write(dir.path().join("readme.txt"), b"not a video").unwrap(); let mut lib = Library::new(); let folder_str = dir.path().to_string_lossy().to_string(); let result = lib.set_root(&folder_str, state_dir.path()); assert!(result.is_ok(), "set_root failed: {:?}", result.err()); let info = result.unwrap(); assert_eq!(info["ok"], true); assert_eq!(info["count"], 3); assert_eq!(lib.fids.len(), 3); assert_eq!(lib.relpaths.len(), 3); assert_eq!(lib.files.len(), 3); // All relpaths should be video files. for rel in &lib.relpaths { assert!(rel.ends_with(".mp4"), "unexpected relpath: {}", rel); } // State file should exist. assert!(lib.state_path.is_some()); assert!(lib.state_path.as_ref().unwrap().exists()); // Library ID should be non-empty. assert!(!lib.state.library_id.is_empty()); } // ----------------------------------------------------------------------- // 9. test_update_progress_finished_sticky // ----------------------------------------------------------------------- #[test] fn test_update_progress_finished_sticky() { let dir = TempDir::new().unwrap(); let state_dir = TempDir::new().unwrap(); fs::write(dir.path().join("video.mp4"), b"content").unwrap(); let mut lib = Library::new(); let folder_str = dir.path().to_string_lossy().to_string(); lib.set_root(&folder_str, state_dir.path()).unwrap(); // Set duration and advance to near the end to trigger finished. let result = lib.update_progress(0, 98.5, Some(100.0), true); assert_eq!(result["finished"], true); // Now go back to an earlier time -- finished should remain true. let result = lib.update_progress(0, 10.0, Some(100.0), true); assert_eq!(result["finished"], true); // Verify pos updated but finished stayed sticky. assert_eq!(result["pos"], 10.0); // Watched should still be the high-water mark. assert_eq!(result["watched"], 98.5); } // ----------------------------------------------------------------------- // 10. test_reset_watch_progress // ----------------------------------------------------------------------- #[test] fn test_reset_watch_progress() { let dir = TempDir::new().unwrap(); let state_dir = TempDir::new().unwrap(); fs::write(dir.path().join("vid1.mp4"), b"video 1").unwrap(); fs::write(dir.path().join("vid2.mp4"), b"video 2").unwrap(); let mut lib = Library::new(); let folder_str = dir.path().to_string_lossy().to_string(); lib.set_root(&folder_str, state_dir.path()).unwrap(); // Add some progress and notes. lib.update_progress(0, 50.0, Some(100.0), true); lib.update_progress(1, 99.0, Some(100.0), true); lib.set_note(&lib.fids[0].clone(), "Important lecture"); // Verify pre-conditions. let fid0 = lib.fids[0].clone(); let fid1 = lib.fids[1].clone(); assert!(lib.state.videos[&fid0].watched > 0.0); assert!(lib.state.videos[&fid1].finished); assert!(!lib.state.videos[&fid0].note.is_empty()); assert!(lib.state.videos[&fid0].duration.is_some()); // Reset. let result = lib.reset_watch_progress(); assert_eq!(result["ok"], true); // Progress should be reset. assert_eq!(lib.state.videos[&fid0].pos, 0.0); assert_eq!(lib.state.videos[&fid0].watched, 0.0); assert!(!lib.state.videos[&fid0].finished); assert_eq!(lib.state.videos[&fid1].pos, 0.0); assert_eq!(lib.state.videos[&fid1].watched, 0.0); assert!(!lib.state.videos[&fid1].finished); // Notes and durations should be preserved. assert_eq!(lib.state.videos[&fid0].note, "Important lecture"); assert!(lib.state.videos[&fid0].duration.is_some()); assert!(lib.state.videos[&fid1].duration.is_some()); } }