//! Readers for Curator's *pre-index* metadata layouts. //! //! Before the unified pool index, Tagger / early Curator stored a movie's //! metadata in a per-file sidecar `.{filename}.!tag` (JSON) next to the video. //! When importing a file from such a pool we can recover its ČSFD link (and //! title / year) from that sidecar — or from an old `.Curator.!index` found by //! walking up from the file. Read-only recovery for the import dialog. use std::fs; use std::path::Path; use serde_json::Value; use super::paths; use super::pool_index::INDEX_FILENAME; pub const SIDECAR_SUFFIX: &str = ".!tag"; const MAX_INDEX_WALK_UP: usize = 4; /// What an old sidecar / index record can still tell us about a movie. #[derive(Debug, Clone, Default, PartialEq)] pub struct LegacyMeta { pub csfd_url: Option, pub title: Option, pub year: Option, /// "sidecar" | "index". pub source: String, } fn nonempty_str(v: Option<&Value>) -> Option { v.and_then(Value::as_str) .filter(|s| !s.is_empty()) .map(String::from) } /// Pull the useful fields out of a stored movie record (any schema version). fn record_to_meta(record: &Value, source: &str) -> Option { let rec = record.as_object()?; let csfd = nonempty_str(rec.get("csfd_url")).or_else(|| nonempty_str(rec.get("csfd_link"))); let title = nonempty_str(rec.get("title")); let from = |key: &str| { rec.get(key) .and_then(Value::as_object) .and_then(|o| o.get("year")) .cloned() }; let raw_year = from("meta") .filter(super::pool_index::truthy) .or_else(|| from("csfd_cache")) .filter(|v| !v.is_null()); let year = raw_year .map(|v| { v.as_str() .map_or_else(|| v.to_string(), String::from) .trim() .to_string() }) .filter(|y| !y.is_empty() && y.chars().all(|c| c.is_ascii_digit())); if csfd.is_none() && title.is_none() && year.is_none() { return None; } Some(LegacyMeta { csfd_url: csfd, title, year, source: source.into(), }) } fn read_json(path: &Path) -> Option { serde_json::from_str(&fs::read_to_string(path).ok()?).ok() } fn from_sidecar(video_path: &Path) -> Option { let sidecar = video_path .parent()? .join(format!(".{}{SIDECAR_SUFFIX}", paths::name(video_path))); if !sidecar.is_file() { return None; } record_to_meta(&read_json(&sidecar)?, "sidecar") } /// The record for `video_path` in an index's `movies` map — exact pool-relative /// key first, then a unique basename match. fn lookup_in_index<'a>( movies: &'a serde_json::Map, video_path: &Path, index_dir: &Path, ) -> Option<&'a Value> { if video_path.starts_with(index_dir) && let Some(rec) = movies.get(&paths::posix_relative(video_path, index_dir)) { return Some(rec); } let name = paths::name(video_path); let matches: Vec<&Value> = movies .iter() .filter(|(k, _)| k.rsplit('/').next() == Some(name.as_str())) .map(|(_, v)| v) .collect(); (matches.len() == 1).then(|| matches[0]) } fn from_legacy_index(video_path: &Path) -> Option { let mut directory = video_path.parent()?.to_path_buf(); for _ in 0..=MAX_INDEX_WALK_UP { let index_path = directory.join(INDEX_FILENAME); if index_path.is_file() { let data = read_json(&index_path)?; let obj = data.as_object()?; let movies = match obj.get("movies") { Some(m) => m.as_object()?, None => obj, }; return record_to_meta(lookup_in_index(movies, video_path, &directory)?, "index"); } match directory.parent() { Some(parent) if parent != directory => directory = parent.to_path_buf(), _ => break, } } None } /// Recover ČSFD link / title / year from a pre-index layout: a `.{name}.!tag` /// sidecar next to the video, then a `.Curator.!index` up to 4 levels up. pub fn read_legacy_meta(video_path: &Path) -> Option { from_sidecar(video_path).or_else(|| from_legacy_index(video_path)) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn video(dir: &Path, rel: &str) -> std::path::PathBuf { let p = dir.join(rel); fs::create_dir_all(p.parent().unwrap()).unwrap(); fs::write(&p, "x").unwrap(); p } #[test] fn test_reads_tagger_era_sidecar() { let dir = tempfile::tempdir().unwrap(); let v = video(dir.path(), "Matrix.mkv"); fs::write(dir.path().join(".Matrix.mkv.!tag"), json!({"csfd_link": "https://www.csfd.cz/film/9499/", "title": "Matrix", "csfd_cache": {"year": 1999}}).to_string()).unwrap(); let meta = read_legacy_meta(&v).unwrap(); assert_eq!( meta.csfd_url.as_deref(), Some("https://www.csfd.cz/film/9499/") ); assert_eq!(meta.year.as_deref(), Some("1999")); assert_eq!(meta.source, "sidecar"); } #[test] fn test_reads_old_index_by_key_and_by_basename() { let dir = tempfile::tempdir().unwrap(); let v = video(dir.path(), "Filmy/sub/Matrix.mkv"); fs::write(dir.path().join(INDEX_FILENAME), json!({"movies": {"Filmy/sub/Matrix.mkv": {"csfd_url": "u1"}, "Old/Other.mkv": {"title": "Other"}}}).to_string()).unwrap(); assert_eq!( read_legacy_meta(&v).unwrap().csfd_url.as_deref(), Some("u1") ); let moved = video(dir.path(), "Elsewhere/Other.mkv"); assert_eq!( read_legacy_meta(&moved).unwrap().title.as_deref(), Some("Other") ); } #[test] fn test_sidecar_wins_over_index() { let dir = tempfile::tempdir().unwrap(); let v = video(dir.path(), "Matrix.mkv"); fs::write( dir.path().join(".Matrix.mkv.!tag"), json!({"csfd_url": "side"}).to_string(), ) .unwrap(); fs::write( dir.path().join(INDEX_FILENAME), json!({"movies": {"Matrix.mkv": {"csfd_url": "idx"}}}).to_string(), ) .unwrap(); assert_eq!( read_legacy_meta(&v).unwrap().csfd_url.as_deref(), Some("side") ); } #[test] fn test_none_cases() { let dir = tempfile::tempdir().unwrap(); let v = video(dir.path(), "Matrix.mkv"); assert!(read_legacy_meta(&v).is_none()); fs::write(dir.path().join(".Matrix.mkv.!tag"), "{broken").unwrap(); assert!(read_legacy_meta(&v).is_none()); fs::write( dir.path().join(".Matrix.mkv.!tag"), json!({"tags": []}).to_string(), ) .unwrap(); assert!(read_legacy_meta(&v).is_none()); } }