use std::path::Path; use tokio::process::Command; const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "webm", "m4v"]; /// In-memory representation of a scanned local video file. #[derive(Debug, Clone)] pub struct LocalFileItem { /// Relative path from root, with forward slashes (used as the stable ID source). pub rel_path: String, pub title: String, pub duration_secs: u32, pub year: Option, /// Ancestor directory names between root and file (excluding root itself). pub tags: Vec, /// First path component under root (used as collection id/name). pub top_dir: String, } /// Walk `root` and return all recognised video files with metadata. /// /// ffprobe is called for each file to determine duration. Files that cannot be /// probed are included with `duration_secs = 0` so they still appear in the index. pub async fn scan_dir(root: &Path) -> Vec { let mut items = Vec::new(); let walker = walkdir::WalkDir::new(root).follow_links(true); for entry in walker.into_iter().filter_map(|e| e.ok()) { if !entry.file_type().is_file() { continue; } let path = entry.path(); let ext = path .extension() .and_then(|e| e.to_str()) .map(|e| e.to_lowercase()); let ext = match ext { Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => e.clone(), _ => continue, }; let _ = ext; // extension validated, not needed further let rel = match path.strip_prefix(root) { Ok(r) => r, Err(_) => continue, }; // Normalise to forward-slash string for cross-platform stability. let rel_path: String = rel .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect::>() .join("/"); // Top-level directory under root. let top_dir = rel .components() .next() .filter(|_| rel.components().count() > 1) // skip if file is at root level .map(|c| c.as_os_str().to_string_lossy().into_owned()) .unwrap_or_else(|| "__root__".to_string()); // Title: stem with separator chars replaced by spaces. let stem = path .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); let title = stem.replace(['_', '-', '.'], " "); let title = title.trim().to_string(); // Year: first 4-digit number starting with 19xx or 20xx in filename or parent dirs. let search_str = format!( "{} {}", stem, rel.parent() .and_then(|p| p.to_str()) .unwrap_or("") ); let year = extract_year(&search_str); // Tags: ancestor directory components between root and the file. let tags: Vec = rel .parent() .into_iter() .flat_map(|p| p.components()) .map(|c| c.as_os_str().to_string_lossy().into_owned()) .filter(|s| !s.is_empty()) .collect(); let duration_secs = get_duration(path).await.unwrap_or(0); items.push(LocalFileItem { rel_path, title, duration_secs, year, tags, top_dir, }); } items } /// Extract the first plausible 4-digit year (1900–2099) from `s`. fn extract_year(s: &str) -> Option { let chars: Vec = s.chars().collect(); let n = chars.len(); if n < 4 { return None; } for i in 0..=(n - 4) { // All four chars must be ASCII digits. if !chars[i..i + 4].iter().all(|c| c.is_ascii_digit()) { continue; } // Parse and range-check. let s4: String = chars[i..i + 4].iter().collect(); let num: u16 = s4.parse().ok()?; if !(1900..=2099).contains(&num) { continue; } // Word-boundary: char before and after must not be digits. let before_ok = i == 0 || !chars[i - 1].is_ascii_digit(); let after_ok = i + 4 >= n || !chars[i + 4].is_ascii_digit(); if before_ok && after_ok { return Some(num); } } None } /// Run ffprobe to get the duration of `path` in whole seconds. async fn get_duration(path: &Path) -> Option { #[derive(serde::Deserialize)] struct Fmt { duration: Option, } #[derive(serde::Deserialize)] struct Out { format: Fmt, } let output = Command::new("ffprobe") .args([ "-v", "quiet", "-print_format", "json", "-show_format", path.to_str()?, ]) .output() .await .ok()?; let parsed: Out = serde_json::from_slice(&output.stdout).ok()?; let dur: f64 = parsed.format.duration?.parse().ok()?; Some(dur as u32) }