Rewrite Curator in Rust with an egui GUI
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
//! 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user