//! Unified metadata index for the Curator pool. //! //! The whole pool keeps a single JSON index at `/.Curator.!index`. Curator //! owns the pool, so files never move behind its back and a central index is safe. //! The index also carries **library-level settings** (`tag_schema`, `libraries`, //! …) under `settings`, so everything describing a pool travels with it. //! //! On-disk shape (**v2**): //! //! ```text //! {"version": 2, //! "settings": {...}, //! "movies": {"tmdb-603": {"title": …, "media": [{"role", "label", "file"}, …]}}} //! ``` //! //! Records are keyed by the movie's identity and own their files. A **v1** index //! (keyed by one file's pool-relative path) is migrated in place on load, after //! backing the old file up. Migration only rewrites metadata. use std::fs; use std::path::{Path, PathBuf}; use serde_json::{Map, Value, json}; use tracing::warn; use super::error::CoreResult; use super::movie::{ROLE_VIDEO, identity_key_for}; use super::naming::{is_canonical_pool_stem, parse_pool_stem, parse_tmdb_id}; use super::paths; pub const INDEX_FILENAME: &str = ".Curator.!index"; pub const INDEX_VERSION: i64 = 2; /// Single-file JSON metadata store for a pool. #[derive(Debug, Clone)] pub struct PoolIndex { pub pool_dir: PathBuf, pub index_path: PathBuf, pub records: Map, pub settings: Map, } impl PoolIndex { /// Open (and, if needed, migrate) the index of `pool_dir`. pub fn open(pool_dir: &Path) -> Self { let mut idx = Self { pool_dir: pool_dir.to_path_buf(), index_path: pool_dir.join(INDEX_FILENAME), records: Map::new(), settings: Map::new(), }; idx.load(); idx } /// Pool-relative POSIX key for a file path. pub fn rel_key(&self, file_path: &Path) -> String { paths::posix_relative(file_path, &self.pool_dir) } /// Load from disk (missing/corrupt index = empty). pub fn load(&mut self) { self.records.clear(); self.settings.clear(); let Ok(text) = fs::read_to_string(&self.index_path) else { return; }; let data: Value = match serde_json::from_str(&text) { Ok(v) => v, Err(e) => { warn!("pool index {} is corrupt: {e}", self.index_path.display()); return; } }; self.records = data .get("movies") .and_then(Value::as_object) .cloned() .unwrap_or_default(); self.settings = data .get("settings") .and_then(Value::as_object) .cloned() .unwrap_or_default(); let version = data.get("version").and_then(Value::as_i64).unwrap_or(1); if version < INDEX_VERSION { self.migrate_v1_to_v2(); } } /// Re-key path-keyed records into identity-keyed movie records, merging the /// several v1 records that describe one multi-version movie. fn migrate_v1_to_v2(&mut self) { let mut migrated: Map = Map::new(); for (rel_path, record) in &self.records { let Some(rec) = record.as_object() else { continue; }; if rec.contains_key("media") { continue; // already v2-shaped } let parts: Vec<&str> = rel_path.split('/').filter(|p| !p.is_empty()).collect(); let library = if parts.len() > 1 { parts[0] } else { "" }; let path = Path::new(rel_path.as_str()); let file_stem = paths::stem(path); let parent = if parts.len() > 1 { parts[parts.len() - 2] } else { "" }; let grouped = parts.len() > 2 && is_canonical_pool_stem(parent); let identity_stem = if grouped { parent.to_string() } else { file_stem.clone() }; let parsed = parse_pool_stem(&identity_stem); let title = rec .get("title") .and_then(Value::as_str) .filter(|s| !s.is_empty()) .map(String::from) .unwrap_or_else(|| parsed.as_ref().map_or(file_stem.clone(), |p| p.0.clone())); let year = parsed.as_ref().map(|p| p.1.to_string()); let tmdb_id = parse_tmdb_id(&identity_stem); let label = if grouped { match file_stem.strip_prefix(&format!("{parent} - ")) { Some(l) => l.to_string(), None => file_stem .get(parent.len()..) .unwrap_or("") .trim_start_matches(['.', ' ', '-']) .to_string(), } } else { String::new() }; let key = identity_key_for(tmdb_id, &title, year.as_deref()); let entry = json!({"role": ROLE_VIDEO, "label": label, "file": rel_path}); if let Some(existing) = migrated.get_mut(&key) { if let Some(media) = existing.get_mut("media").and_then(Value::as_array_mut) { media.push(entry); } continue; } let get = |k: &str| rec.get(k).cloned().unwrap_or(Value::Null); let nonempty = |k: &str| rec.get(k).filter(|v| truthy(v)).cloned(); let mut new = json!({ "title": title, "year": year, "tmdb_id": tmdb_id, "library": library, "media": [entry], "tags": rec.get("tags").cloned().unwrap_or_else(|| json!([])), "auto_tags": rec.get("auto_tags").or_else(|| rec.get("csfd_tags")).cloned().unwrap_or_else(|| json!([])), "csfd_url": nonempty("csfd_url").or_else(|| nonempty("csfd_link")).unwrap_or(Value::Null), "date": get("date"), "added": get("added"), "attributes": rec.get("attributes").cloned().unwrap_or_else(|| json!({})), }); if let (Some(meta), Some(obj)) = (nonempty("meta"), new.as_object_mut()) { obj.insert("meta".into(), meta); } migrated.insert(key, new); } let backup = self .index_path .with_file_name(format!("{INDEX_FILENAME}.v1.bak")); if let Err(e) = fs::copy(&self.index_path, &backup) { warn!("could not back up the v1 index: {e}"); } self.records = migrated; if let Err(e) = self.save() { warn!("could not save the migrated index: {e}"); } } /// Persist the index to disk. pub fn save(&self) -> CoreResult<()> { fs::create_dir_all(&self.pool_dir)?; let data = json!({ "version": INDEX_VERSION, "settings": self.settings, "movies": self.records, }); fs::write(&self.index_path, serde_json::to_string_pretty(&data)?)?; Ok(()) } pub fn get_setting(&self, key: &str) -> Option<&Value> { self.settings.get(key) } /// Upsert a library-level setting and persist the index. pub fn set_setting(&mut self, key: &str, value: Value) -> CoreResult<()> { self.settings.insert(key.into(), value); self.save() } pub fn get_movie(&self, key: &str) -> Option<&Value> { self.records.get(key) } /// Upsert a movie record and persist the index. pub fn set_movie(&mut self, key: &str, data: Value) -> CoreResult<()> { self.records.insert(key.into(), data); self.save() } /// Remove a movie record (if present) and persist the index. pub fn delete_movie(&mut self, key: &str) -> CoreResult<()> { if self.records.shift_remove(key).is_some() { self.save()?; } Ok(()) } /// Move a record to a new identity key (e.g. its TMDb id was set/fixed). pub fn rekey_movie(&mut self, old_key: &str, new_key: &str) -> CoreResult<()> { if old_key == new_key { return Ok(()); } if let Some(rec) = self.records.shift_remove(old_key) { self.records.insert(new_key.into(), rec); self.save()?; } Ok(()) } /// `(key, record)` of the movie owning `file_path`. pub fn find_movie_by_media(&self, file_path: &Path) -> Option<(String, &Value)> { let rel = self.rel_key(file_path); self.records.iter().find_map(|(key, record)| { let owns = record .get("media") .and_then(Value::as_array) .is_some_and(|media| { media .iter() .any(|m| m.get("file").and_then(Value::as_str) == Some(rel.as_str())) }); owns.then(|| (key.clone(), record)) }) } } /// Python-style truthiness of a JSON value. pub(crate) fn truthy(v: &Value) -> bool { match v { Value::Null => false, Value::Bool(b) => *b, Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0), Value::String(s) => !s.is_empty(), Value::Array(a) => !a.is_empty(), Value::Object(o) => !o.is_empty(), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_empty_index_when_no_file() { let dir = tempfile::tempdir().unwrap(); let idx = PoolIndex::open(dir.path()); assert!(idx.records.is_empty()); assert!(!idx.index_path.exists()); } #[test] fn test_set_persists_and_reloads() { let dir = tempfile::tempdir().unwrap(); let mut idx = PoolIndex::open(dir.path()); idx.set_movie("tmdb-603", json!({"title": "Matrix", "media": []})) .unwrap(); let raw: Value = serde_json::from_str(&fs::read_to_string(&idx.index_path).unwrap()).unwrap(); assert_eq!(raw["version"], 2); let again = PoolIndex::open(dir.path()); assert_eq!(again.get_movie("tmdb-603").unwrap()["title"], "Matrix"); } #[test] fn test_delete_and_rekey() { let dir = tempfile::tempdir().unwrap(); let mut idx = PoolIndex::open(dir.path()); idx.set_movie("name-matrix-1999", json!({"title": "Matrix", "media": []})) .unwrap(); idx.rekey_movie("name-matrix-1999", "tmdb-603").unwrap(); assert!(idx.get_movie("name-matrix-1999").is_none()); assert!(idx.get_movie("tmdb-603").is_some()); idx.delete_movie("tmdb-603").unwrap(); assert!(PoolIndex::open(dir.path()).records.is_empty()); } #[test] fn test_find_movie_by_media() { let dir = tempfile::tempdir().unwrap(); let mut idx = PoolIndex::open(dir.path()); idx.set_movie( "tmdb-603", json!({"media": [{"role": "video", "label": "", "file": "Filmy/Matrix (1999).mkv"}]}), ) .unwrap(); let found = idx.find_movie_by_media(&dir.path().join("Filmy/Matrix (1999).mkv")); assert_eq!(found.map(|f| f.0), Some("tmdb-603".to_string())); assert!( idx.find_movie_by_media(&dir.path().join("Filmy/Other.mkv")) .is_none() ); } #[test] fn test_corrupt_index_loads_empty() { let dir = tempfile::tempdir().unwrap(); fs::write(dir.path().join(INDEX_FILENAME), "{broken").unwrap(); assert!(PoolIndex::open(dir.path()).records.is_empty()); } fn write_v1(dir: &Path, movies: Value) { let data = json!({"settings": {"tag_schema": []}, "movies": movies}); fs::write( dir.join(INDEX_FILENAME), serde_json::to_string(&data).unwrap(), ) .unwrap(); } #[test] fn test_v1_flat_movie_becomes_identity_keyed() { let dir = tempfile::tempdir().unwrap(); write_v1( dir.path(), json!({"Filmy/Matrix (1999) [tmdbid-603].mkv": { "title": "Matrix", "tags": ["Žánr/Akční"], "csfd_link": "https://csfd/1", "meta": {"year": "1999"}}}), ); let idx = PoolIndex::open(dir.path()); let rec = idx.get_movie("tmdb-603").unwrap(); assert_eq!(rec["year"], "1999"); assert_eq!(rec["library"], "Filmy"); assert_eq!(rec["csfd_url"], "https://csfd/1"); assert_eq!( rec["media"][0]["file"], "Filmy/Matrix (1999) [tmdbid-603].mkv" ); assert_eq!(rec["meta"]["year"], "1999"); assert!(dir.path().join(format!("{INDEX_FILENAME}.v1.bak")).exists()); let raw: Value = serde_json::from_str(&fs::read_to_string(&idx.index_path).unwrap()).unwrap(); assert_eq!(raw["version"], 2); assert_eq!(raw["settings"], json!({"tag_schema": []})); } #[test] fn test_v1_grouped_versions_merge() { let dir = tempfile::tempdir().unwrap(); let folder = "Filmy/Avatar (2009) [tmdbid-19995]"; write_v1( dir.path(), json!({ format!("{folder}/Avatar (2009) [tmdbid-19995] - 4K.mkv"): {"title": "Avatar"}, format!("{folder}/Avatar (2009) [tmdbid-19995].3D.HSBS.mkv"): {"title": "Avatar"}, }), ); let idx = PoolIndex::open(dir.path()); let labels: Vec<&str> = idx.get_movie("tmdb-19995").unwrap()["media"] .as_array() .unwrap() .iter() .map(|m| m["label"].as_str().unwrap()) .collect(); assert_eq!(labels, vec!["4K", "3D.HSBS"]); } #[test] fn test_v1_without_tmdbid_gets_slug_key() { let dir = tempfile::tempdir().unwrap(); write_v1(dir.path(), json!({"Filmy/Pelíšky (1999).mkv": {}})); assert!( PoolIndex::open(dir.path()) .get_movie("name-pelisky-1999") .is_some() ); } }