211 lines
6.8 KiB
Rust
211 lines
6.8 KiB
Rust
//! 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());
|
|
}
|
|
}
|