use std::path::Path; use tokio::process::Command; const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mkv", "avi", "mov", "webm", "m4v"]; const ROOT_COLLECTION_NAME: &str = "__root__"; const YEAR_DIGITS: usize = 4; const MIN_YEAR: u16 = 1900; const MAX_YEAR: u16 = 2099; #[derive(Debug, Clone)] pub struct LocalFileItem { pub rel_path: String, pub title: String, pub duration_secs: u32, pub year: Option, pub tags: Vec, pub top_dir: String, } 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()); match ext { Some(ref e) if VIDEO_EXTENSIONS.contains(&e.as_str()) => {} _ => continue, }; let rel = match path.strip_prefix(root) { Ok(r) => r, Err(_) => continue, }; let rel_path: String = rel .components() .map(|c| c.as_os_str().to_string_lossy().into_owned()) .collect::>() .join("/"); let top_dir = rel .components() .next() .filter(|_| rel.components().count() > 1) .map(|c| c.as_os_str().to_string_lossy().into_owned()) .unwrap_or_else(|| ROOT_COLLECTION_NAME.to_string()); 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(); let search_str = format!( "{} {}", stem, rel.parent() .and_then(|p| p.to_str()) .unwrap_or("") ); let year = extract_year(&search_str); 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 } fn extract_year(s: &str) -> Option { let chars: Vec = s.chars().collect(); let n = chars.len(); if n < YEAR_DIGITS { return None; } for i in 0..=(n - YEAR_DIGITS) { if !chars[i..i + YEAR_DIGITS].iter().all(|c| c.is_ascii_digit()) { continue; } let s4: String = chars[i..i + YEAR_DIGITS].iter().collect(); let num: u16 = s4.parse().ok()?; if !(MIN_YEAR..=MAX_YEAR).contains(&num) { continue; } let before_ok = i == 0 || !chars[i - 1].is_ascii_digit(); let after_ok = i + YEAR_DIGITS >= n || !chars[i + YEAR_DIGITS].is_ascii_digit(); if before_ok && after_ok { return Some(num); } } None } 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) } #[cfg(test)] mod tests { use super::*; #[test] fn extract_year_basic() { assert_eq!(extract_year("Movie 2024 HD"), Some(2024)); assert_eq!(extract_year("1999_classic"), Some(1999)); assert_eq!(extract_year("no year here"), None); assert_eq!(extract_year("12345"), None); assert_eq!(extract_year("2100"), None); assert_eq!(extract_year("1900"), Some(1900)); assert_eq!(extract_year("2099"), Some(2099)); } }