Files
Curator/src/core/pool_index.rs
T

389 lines
14 KiB
Rust

//! Unified metadata index for the Curator pool.
//!
//! The whole pool keeps a single JSON index at `<pool>/.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<String, Value>,
pub settings: Map<String, Value>,
}
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<String, Value> = 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()
);
}
}