Rewrite Curator in Rust with an egui GUI

This commit is contained in:
2026-10-17 11:28:17 +02:00
parent 364b515e3c
commit 65b5b74c9f
156 changed files with 24359 additions and 25795 deletions
+210
View File
@@ -0,0 +1,210 @@
//! 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<String>,
pub title: Option<String>,
pub year: Option<String>,
/// "sidecar" | "index".
pub source: String,
}
fn nonempty_str(v: Option<&Value>) -> Option<String> {
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<LegacyMeta> {
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<Value> {
serde_json::from_str(&fs::read_to_string(path).ok()?).ok()
}
fn from_sidecar(video_path: &Path) -> Option<LegacyMeta> {
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<String, Value>,
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<LegacyMeta> {
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<LegacyMeta> {
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());
}
}