389 lines
14 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|