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
+870
View File
@@ -0,0 +1,870 @@
//! `FileManager` — the service layer and the only thing that changes the pool.
//!
//! Everything the user can do maps onto one of its public methods. Methods that
//! change files also keep the pool index and the in-memory `filelist` in step.
//! Files are addressed by their current path.
mod minidlna;
mod movies;
mod series_ops;
mod tools;
mod versions;
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use super::config::{
GlobalConfig, TagSchemaEntry, default_tag_schema, global_config_path, load_global_config_from,
save_global_config_to,
};
use super::error::{CoreError, CoreResult};
use super::file::File;
use super::naming::is_canonical_pool_stem;
use super::paths;
use super::pool_index::PoolIndex;
use super::series::{Series, looks_like_series_folder, scan_series};
use super::tag::Tag;
use super::tag_manager::TagManager;
pub use minidlna::{GenerateReport, MIXED_SERIES_SUBFOLDER, RECENT_SUBFOLDER, TIPS_SUBFOLDER};
pub use movies::{ImportOptions, MovieEdits, OnConflict};
pub use series_ops::{
EpisodeCode, ImportEpisodesResult, RenameAllSeriesResult, RenameEpisodesResult,
};
pub use tools::{
ConsistencyReport, DuplicateGroup, IntegrityReport, JellyfinReport, MinidlnaScope,
RefreshReport, RenameAllReport, ResolveResult, Stats,
};
pub use versions::{GroupMode, label_of, version_filename};
/// Top-level folders of a fresh pool.
pub const POOL_MOVIES: &str = "Filmy";
pub const POOL_SERIES: &str = "Seriály";
/// Curator metadata files that must never be treated as content.
pub const METADATA_SUFFIXES: &[&str] = &[".!tag", ".!ftag", ".!gtag", ".!index"];
/// Sidecars Jellyfin writes next to a movie (NFO + artwork / subtitles).
pub const SIDECAR_SUFFIXES: &[&str] = &[
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn", ".srt", ".ass", ".ssa",
".sub", ".idx", ".vtt",
];
/// True for files that are pool content (not Curator metadata / Jellyfin sidecars).
pub fn is_media_file(path: &Path) -> bool {
let name = paths::name(path);
!METADATA_SUFFIXES.iter().any(|s| name.ends_with(s))
&& !SIDECAR_SUFFIXES.contains(&paths::suffix_lower(path).as_str())
}
/// The kind of a pool library (top-level folder).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LibraryKind {
/// Single-file movies, TMDb naming.
Movie,
/// Series/season/episode tree (Jellyfin Shows).
Series,
/// Movies at the top level, series in subfolders.
Mixed,
/// Files without TMDb (home videos), mirrored as-is.
HomeVideo,
}
impl LibraryKind {
pub const ALL: [LibraryKind; 4] = [Self::Movie, Self::Series, Self::Mixed, Self::HomeVideo];
pub fn as_str(self) -> &'static str {
match self {
Self::Movie => "movie",
Self::Series => "series",
Self::Mixed => "mixed",
Self::HomeVideo => "homevideo",
}
}
/// Unknown kinds read as `movie`, like the Python version.
pub fn parse(s: &str) -> Self {
match s {
"series" => Self::Series,
"mixed" => Self::Mixed,
"homevideo" => Self::HomeVideo,
_ => Self::Movie,
}
}
/// Czech label for the library organiser.
pub fn label(self) -> &'static str {
match self {
Self::Movie => "Filmy (jednotlivé filmy)",
Self::Series => "Seriály",
Self::Mixed => "Mix (filmy + seriály)",
Self::HomeVideo => "Domácí videa (bez TMDb)",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Library {
pub name: String,
pub kind: LibraryKind,
}
impl Library {
pub fn new(name: &str, kind: LibraryKind) -> Self {
Self {
name: name.into(),
kind,
}
}
}
fn default_libraries() -> Vec<Library> {
vec![
Library::new(POOL_MOVIES, LibraryKind::Movie),
Library::new(POOL_SERIES, LibraryKind::Series),
]
}
/// Library settings that moved from the old global config into the index.
const LEGACY_LIBRARY_SETTINGS: &[&str] = &["tag_schema", "copyasis_folders", "movie_libraries"];
fn clean_libraries(specs: impl IntoIterator<Item = Library>) -> Vec<Library> {
let mut seen = HashSet::new();
specs
.into_iter()
.filter_map(|l| {
let name = l.name.trim().to_string();
(!name.is_empty() && seen.insert(name.to_lowercase()))
.then_some(Library { name, kind: l.kind })
})
.collect()
}
fn str_list(v: Option<&Value>) -> Option<Vec<String>> {
v.and_then(Value::as_array).map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(String::from))
.collect()
})
}
pub struct FileManager {
pub filelist: Vec<File>,
pub tagmanager: TagManager,
pub global_config: GlobalConfig,
/// Unified pool metadata + library settings.
pub index: Option<PoolIndex>,
config_path: PathBuf,
}
impl FileManager {
/// A manager using the per-user global config.
pub fn new() -> Self {
Self::with_config_path(global_config_path())
}
/// A manager reading/writing its global config at `config_path` (tests use
/// a temporary one). Falls back to a legacy `.Curator.!gtag` in the working
/// directory on first run.
pub fn with_config_path(config_path: PathBuf) -> Self {
let global_config = if config_path.exists() {
load_global_config_from(&config_path)
} else if config_path == global_config_path() {
super::config::load_global_config()
} else {
GlobalConfig::default()
};
Self {
filelist: Vec::new(),
tagmanager: TagManager::new(),
global_config,
index: None,
config_path,
}
}
pub fn save_global_config(&self) -> CoreResult<()> {
save_global_config_to(&self.global_config, &self.config_path)
}
// ------------------------------------------------------------------
// Pool and output folders
// ------------------------------------------------------------------
pub fn pool_dir(&self) -> Option<PathBuf> {
self.global_config
.pool_dir
.as_deref()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub fn filmoteka_dir(&self) -> Option<PathBuf> {
self.global_config
.filmoteka_dir
.as_deref()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
}
pub(crate) fn require_pool(&self) -> CoreResult<PathBuf> {
self.pool_dir()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))
}
/// Set the pool root and create every library's top-level folder.
pub fn set_pool_dir(&mut self, pool_dir: &Path) -> CoreResult<()> {
self.global_config.pool_dir = Some(pool_dir.to_string_lossy().into_owned());
self.save_global_config()?;
self.open_index(pool_dir)?;
for lib in self.libraries() {
fs::create_dir_all(pool_dir.join(&lib.name))?;
}
Ok(())
}
/// Set the miniDLNA output folder.
pub fn set_filmoteka_dir(&mut self, dir: &Path) -> CoreResult<()> {
self.global_config.filmoteka_dir = Some(dir.to_string_lossy().into_owned());
self.save_global_config()
}
// ------------------------------------------------------------------
// Pool index + library-level settings
// ------------------------------------------------------------------
/// (Re)load the pool index and migrate legacy global settings into it.
fn open_index(&mut self, pool: &Path) -> CoreResult<()> {
self.index = Some(PoolIndex::open(pool));
self.migrate_global_settings()
}
pub(crate) fn ensure_index(&mut self) -> Option<&mut PoolIndex> {
if self.index.is_none()
&& let Some(pool) = self.pool_dir()
{
let _ = self.open_index(&pool);
}
self.index.as_mut()
}
fn index_ref(&self) -> Option<&PoolIndex> {
self.index.as_ref()
}
/// Move library settings from the old global config into the index once.
fn migrate_global_settings(&mut self) -> CoreResult<()> {
let Some(index) = self.index.as_mut() else {
return Ok(());
};
let mut moved = false;
for key in LEGACY_LIBRARY_SETTINGS {
if let Some(value) = self.global_config.extra.shift_remove(*key) {
if !index.settings.contains_key(*key) {
index.set_setting(key, value)?;
}
moved = true;
}
}
if moved {
self.save_global_config()?;
}
Ok(())
}
fn setting(&self, key: &str) -> Option<&Value> {
self.index_ref()?.get_setting(key)
}
fn set_setting(&mut self, key: &str, value: Value) -> CoreResult<()> {
let index = self
.ensure_index()
.ok_or_else(|| CoreError::invalid("Pool není nastaven — není kam uložit nastavení."))?;
index.set_setting(key, value)
}
/// All pool libraries (source of truth). Old pools are synthesised from the
/// legacy `movie_libraries` + `copyasis_folders` settings.
pub fn libraries(&self) -> Vec<Library> {
let raw: Vec<Library> = match self.setting("libraries").and_then(Value::as_array) {
Some(arr) => arr
.iter()
.map(|spec| Library {
name: spec
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
kind: LibraryKind::parse(
spec.get("kind").and_then(Value::as_str).unwrap_or("movie"),
),
})
.collect(),
None => {
let movies = str_list(self.setting("movie_libraries"))
.unwrap_or_else(|| vec![POOL_MOVIES.into()]);
let series = str_list(self.setting("copyasis_folders"))
.unwrap_or_else(|| vec![POOL_SERIES.into()]);
movies
.iter()
.map(|n| Library::new(n, LibraryKind::Movie))
.chain(series.iter().map(|n| Library::new(n, LibraryKind::Series)))
.collect()
}
};
let out = clean_libraries(raw);
if out.is_empty() {
default_libraries()
} else {
out
}
}
/// Replace the library list and persist it.
pub fn set_libraries(&mut self, specs: Vec<Library>) -> CoreResult<()> {
let mut cleaned = clean_libraries(specs);
if cleaned.is_empty() {
cleaned = default_libraries();
}
let value = Value::Array(
cleaned
.iter()
.map(|l| json!({"name": l.name, "kind": l.kind.as_str()}))
.collect(),
);
self.set_setting("libraries", value)
}
pub fn libraries_of_kind(&self, kinds: &[LibraryKind]) -> Vec<String> {
self.libraries()
.into_iter()
.filter(|l| kinds.contains(&l.kind))
.map(|l| l.name)
.collect()
}
pub fn library_kind(&self, name: &str) -> Option<LibraryKind> {
self.libraries()
.into_iter()
.find(|l| l.name == name)
.map(|l| l.kind)
}
/// Libraries holding single-file movies (never empty).
pub fn movie_libraries(&self) -> Vec<String> {
let libs = self.libraries_of_kind(&[LibraryKind::Movie]);
if libs.is_empty() {
vec![POOL_MOVIES.into()]
} else {
libs
}
}
/// Libraries a movie can live in — kind movie or mixed (never empty).
pub fn libraries_accepting_movies(&self) -> Vec<String> {
let libs = self.libraries_of_kind(&[LibraryKind::Movie, LibraryKind::Mixed]);
if libs.is_empty() {
vec![POOL_MOVIES.into()]
} else {
libs
}
}
/// Libraries that can hold series — kind series or mixed.
pub fn series_libraries(&self) -> Vec<String> {
self.libraries_of_kind(&[LibraryKind::Series, LibraryKind::Mixed])
}
/// The series (copy-as-is) libraries.
pub fn copyasis_folders(&self) -> Vec<String> {
self.libraries_of_kind(&[LibraryKind::Series])
}
/// Libraries hardcopied 1:1 into miniDLNA — series and homevideo.
pub fn mirror_as_is_libraries(&self) -> Vec<String> {
self.libraries_of_kind(&[LibraryKind::Series, LibraryKind::HomeVideo])
}
/// Libraries included in miniDLNA generation, `None` = all. Unknown names
/// are dropped on read.
pub fn minidlna_libraries(&self) -> Option<Vec<String>> {
let raw = str_list(self.setting("minidlna_libraries"))?;
let known: HashSet<String> = self.libraries().into_iter().map(|l| l.name).collect();
Some(raw.into_iter().filter(|n| known.contains(n)).collect())
}
/// Restrict miniDLNA generation to `names`; `None` resets to "all".
pub fn set_minidlna_libraries(&mut self, names: Option<Vec<String>>) -> CoreResult<()> {
let value = match names {
None => Value::Null,
Some(list) => {
let mut seen = HashSet::new();
Value::Array(
list.into_iter()
.filter(|n| seen.insert(n.clone()))
.map(Value::from)
.collect(),
)
}
};
if value.is_null() {
let index = self.ensure_index().ok_or_else(|| {
CoreError::invalid("Pool není nastaven — není kam uložit nastavení.")
})?;
index
.settings
.insert("minidlna_libraries".into(), Value::Null);
return index.save();
}
self.set_setting("minidlna_libraries", value)
}
/// Top-level pool folders that aren't a configured library: `(name, is_empty)`.
pub fn orphan_pool_folders(&self) -> Vec<(String, bool)> {
let Some(pool) = self.pool_dir().filter(|p| p.is_dir()) else {
return Vec::new();
};
let known: HashSet<String> = self.libraries().into_iter().map(|l| l.name).collect();
paths::subdirs(&pool)
.into_iter()
.filter(|d| !known.contains(&paths::name(d)))
.map(|d| (paths::name(&d), paths::is_empty_dir(&d)))
.collect()
}
/// Delete an **empty** orphan folder in the pool root.
pub fn remove_empty_pool_folder(&self, name: &str) -> bool {
let Some(pool) = self.pool_dir() else {
return false;
};
let known = self.libraries().iter().any(|l| l.name == name);
let target = pool.join(name);
!known && paths::is_empty_dir(&target) && fs::remove_dir(&target).is_ok()
}
/// Which library (top-level pool folder) a pooled path lives in.
pub fn library_of(&self, path: &Path) -> Option<String> {
let pool = self.pool_dir()?;
let rel = path.strip_prefix(&pool).ok()?;
rel.components()
.next()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
}
pub fn tag_schema(&self) -> Vec<TagSchemaEntry> {
self.setting("tag_schema")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_else(default_tag_schema)
}
pub fn set_tag_schema(&mut self, schema: &[TagSchemaEntry]) -> CoreResult<()> {
self.set_setting("tag_schema", serde_json::to_value(schema)?)
}
/// The miniDLNA tree spec derived from the tag schema.
pub fn tree_spec(&self) -> super::hardlink_manager::TreeSpec {
let schema = self.tag_schema();
let mut spec = super::hardlink_manager::TreeSpec {
roots: Some(
schema
.iter()
.filter_map(|e| Some((e.category.clone(), e.filmoteka_root.clone()?)))
.collect(),
),
..Default::default()
};
for e in schema.iter().filter(|e| e.filmoteka_root.is_some()) {
if let Some(t) = e.transform.clone().filter(|t| !t.is_empty()) {
spec.transforms.insert(e.category.clone(), t);
}
if let Some(t) = e.filename_template.clone().filter(|t| !t.is_empty()) {
spec.templates.insert(e.category.clone(), t);
}
}
spec
}
// ------------------------------------------------------------------
// Loading
// ------------------------------------------------------------------
/// Reload the movie list from every movie/mixed library via the index.
pub fn load_pool_movies(&mut self) -> CoreResult<()> {
self.filelist.clear();
self.tagmanager.clear();
let Some(pool) = self.pool_dir() else {
return Ok(());
};
self.open_index(&pool)?;
let mut candidates = Vec::new();
for lib in self.libraries() {
let lib_dir = pool.join(&lib.name);
if !lib_dir.is_dir() {
continue;
}
match lib.kind {
LibraryKind::Movie => candidates.extend(paths::walk_files(&lib_dir)),
LibraryKind::Mixed => {
// A mixed library keeps movies at the top level and series in
// subfolders; a movie's version folder has the same shape as a
// series folder but never contains a Season folder.
candidates.extend(paths::entries(&lib_dir).into_iter().filter(|p| p.is_file()));
for sub in paths::subdirs(&lib_dir) {
if is_canonical_pool_stem(&paths::name(&sub))
&& !looks_like_series_folder(&sub)
{
candidates
.extend(paths::entries(&sub).into_iter().filter(|p| p.is_file()));
}
}
}
_ => {}
}
}
let index = self
.index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
for path in candidates.into_iter().filter(|p| is_media_file(p)) {
self.filelist
.push(File::load(&path, &mut self.tagmanager, Some(index))?);
}
Ok(())
}
/// Scan every series library.
pub fn load_series(&self) -> Vec<Series> {
let Some(pool) = self.pool_dir() else {
return Vec::new();
};
self.libraries()
.into_iter()
.filter(|l| matches!(l.kind, LibraryKind::Series | LibraryKind::Mixed))
.flat_map(|l| scan_series(&pool.join(&l.name), &l.name, l.kind == LibraryKind::Mixed))
.collect()
}
// ------------------------------------------------------------------
// File lookup helpers
// ------------------------------------------------------------------
pub fn position(&self, path: &Path) -> Option<usize> {
self.filelist.iter().position(|f| f.file_path == path)
}
pub fn file(&self, path: &Path) -> Option<&File> {
self.filelist.iter().find(|f| f.file_path == path)
}
/// Run `op` on the loaded file at `path`, or on a freshly loaded transient
/// one when it isn't in `filelist`.
pub(crate) fn with_file<R>(
&mut self,
path: &Path,
op: impl FnOnce(&mut File, &mut TagManager, &mut PoolIndex) -> CoreResult<R>,
) -> CoreResult<R> {
if self.ensure_index().is_none() {
return Err(CoreError::invalid("Pool není nastaven."));
}
let Self {
filelist,
tagmanager,
index,
..
} = self;
let index = index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
match filelist.iter_mut().find(|f| f.file_path == path) {
Some(f) => op(f, tagmanager, index),
None => {
let mut f = File::load(path, tagmanager, Some(index))?;
op(&mut f, tagmanager, index)
}
}
}
/// Set (or clear with `None`) the manual date of files.
pub fn set_date(&mut self, files: &[PathBuf], date: Option<&str>) -> CoreResult<()> {
let date = date
.map(str::trim)
.filter(|d| !d.is_empty())
.map(String::from);
for path in files {
let d = date.clone();
self.with_file(path, |f, _, index| {
f.date = d;
f.save_metadata(index)
})?;
}
Ok(())
}
/// Persist a loaded file's metadata.
pub fn save_file(&mut self, path: &Path) -> CoreResult<()> {
self.with_file(path, |f, _, index| f.save_metadata(index))
}
// ------------------------------------------------------------------
// Tags
// ------------------------------------------------------------------
/// Assign a `Category/Name` tag (no category → "default") to files.
pub fn assign_tag(&mut self, files: &[PathBuf], tag: &str) -> CoreResult<()> {
let tag = Tag::from_full_path(tag).unwrap_or_else(|| Tag::new("default", tag));
for path in files {
self.with_file(path, |f, tm, index| {
let t = tm.add_tag(&tag.category, &tag.name);
if !f.tags.contains(&t) {
f.tags.push(t);
f.save_metadata(index)?;
}
Ok(())
})?;
}
Ok(())
}
/// Remove a tag from files.
pub fn remove_tag(&mut self, files: &[PathBuf], tag: &str) -> CoreResult<()> {
let tag = Tag::from_full_path(tag).unwrap_or_else(|| Tag::new("default", tag));
for path in files {
self.with_file(path, |f, _, index| {
if let Some(i) = f.tags.iter().position(|t| *t == tag) {
f.tags.remove(i);
f.save_metadata(index)?;
}
Ok(())
})?;
}
Ok(())
}
/// Files carrying **all** of `tags` (full paths); empty = all files.
pub fn filter_files_by_tags(&self, tags: &HashSet<String>) -> Vec<&File> {
self.filelist
.iter()
.filter(|f| {
tags.iter()
.all(|t| f.tags.iter().any(|ft| &ft.full_path() == t))
})
.collect()
}
}
impl Default for FileManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
/// A manager with a temporary config and pool (`<tmp>/pool`).
pub struct Fixture {
pub dir: tempfile::TempDir,
pub fm: FileManager,
}
impl Fixture {
pub fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let mut fm = FileManager::with_config_path(dir.path().join("cfg.json"));
fm.set_pool_dir(&dir.path().join("pool")).unwrap();
Self { dir, fm }
}
pub fn pool(&self) -> PathBuf {
self.dir.path().join("pool")
}
/// A source file outside the pool.
pub fn source(&self, name: &str) -> PathBuf {
let p = self.dir.path().join("src").join(name);
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(&p, name.as_bytes()).unwrap();
p
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::Fixture;
use super::*;
#[test]
fn test_set_pool_creates_top_level_folders() {
let fx = Fixture::new();
assert!(fx.pool().join("Filmy").is_dir());
assert!(fx.pool().join("Seriály").is_dir());
}
#[test]
fn test_libraries_default_and_set_all_kinds() {
let mut fx = Fixture::new();
assert_eq!(fx.fm.libraries(), default_libraries());
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Kids", LibraryKind::Mixed),
Library::new("Domácí", LibraryKind::HomeVideo),
Library::new("filmy", LibraryKind::Series),
Library::new(" ", LibraryKind::Movie),
])
.unwrap();
let names: Vec<String> = fx.fm.libraries().into_iter().map(|l| l.name).collect();
assert_eq!(names, vec!["Filmy", "Kids", "Domácí"]);
assert_eq!(fx.fm.libraries_accepting_movies(), vec!["Filmy", "Kids"]);
assert_eq!(fx.fm.mirror_as_is_libraries(), vec!["Domácí"]);
assert!(fx.fm.copyasis_folders().is_empty());
}
#[test]
fn test_library_settings_stored_in_index_not_global() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![Library::new("A", LibraryKind::Movie)])
.unwrap();
let raw = fs::read_to_string(fx.pool().join(".Curator.!index")).unwrap();
assert!(raw.contains("\"libraries\""));
let cfg = fs::read_to_string(fx.dir.path().join("cfg.json")).unwrap();
assert!(!cfg.contains("libraries"));
}
#[test]
fn test_migrates_legacy_global_settings_into_index() {
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("cfg.json");
let pool = dir.path().join("pool");
fs::write(
&cfg,
json!({"pool_dir": pool, "copyasis_folders": ["Seriály", "Dokumenty"]}).to_string(),
)
.unwrap();
let mut fm = FileManager::with_config_path(cfg.clone());
fm.load_pool_movies().unwrap();
assert_eq!(fm.copyasis_folders(), vec!["Seriály", "Dokumenty"]);
assert!(
!fs::read_to_string(&cfg)
.unwrap()
.contains("copyasis_folders")
);
}
#[test]
fn test_minidlna_libraries_default_set_and_unknown() {
let mut fx = Fixture::new();
assert_eq!(fx.fm.minidlna_libraries(), None);
fx.fm
.set_minidlna_libraries(Some(vec!["Filmy".into(), "Gone".into(), "Filmy".into()]))
.unwrap();
assert_eq!(fx.fm.minidlna_libraries(), Some(vec!["Filmy".to_string()]));
fx.fm.set_minidlna_libraries(None).unwrap();
assert_eq!(fx.fm.minidlna_libraries(), None);
}
#[test]
fn test_orphan_folder_cleanup() {
let fx = Fixture::new();
fs::create_dir_all(fx.pool().join("Empty")).unwrap();
fs::create_dir_all(fx.pool().join("Full")).unwrap();
fs::write(fx.pool().join("Full/x.mkv"), "").unwrap();
assert_eq!(
fx.fm.orphan_pool_folders(),
vec![("Empty".to_string(), true), ("Full".to_string(), false)]
);
assert!(fx.fm.remove_empty_pool_folder("Empty"));
assert!(!fx.fm.remove_empty_pool_folder("Full"));
assert!(!fx.fm.remove_empty_pool_folder("Filmy"));
}
#[test]
fn test_tree_spec_from_schema() {
let fx = Fixture::new();
let spec = fx.fm.tree_spec();
let roots = spec.roots.unwrap();
assert_eq!(roots["Žánr"], "");
assert_eq!(roots["Rok"], "- Dle roku");
assert_eq!(spec.transforms["Hodnocení"], "decade_band");
}
#[test]
fn test_tag_assign_remove_and_filter() {
let mut fx = Fixture::new();
let a = fx
.fm
.import_movie(&fx.source("a.mkv"), "A", Default::default())
.unwrap()
.unwrap();
let b = fx
.fm
.import_movie(&fx.source("b.mkv"), "B", Default::default())
.unwrap()
.unwrap();
fx.fm
.assign_tag(&[a.clone(), b.clone()], "Žánr/Drama")
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Rok/1999")
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&a), "Rok/1999")
.unwrap();
assert_eq!(fx.fm.file(&a).unwrap().tags.len(), 2);
let want: HashSet<String> = ["Žánr/Drama".to_string(), "Rok/1999".to_string()]
.into_iter()
.collect();
assert_eq!(fx.fm.filter_files_by_tags(&want).len(), 1);
assert_eq!(fx.fm.filter_files_by_tags(&HashSet::new()).len(), 2);
fx.fm
.remove_tag(std::slice::from_ref(&a), "Žánr/Drama")
.unwrap();
assert_eq!(fx.fm.file(&a).unwrap().tags.len(), 1);
fx.fm
.assign_tag(std::slice::from_ref(&b), "bezkategorie")
.unwrap();
assert!(
fx.fm
.file(&b)
.unwrap()
.tags
.contains(&Tag::new("default", "bezkategorie"))
);
}
#[test]
fn test_load_scans_movie_and_mixed_libraries_and_skips_sidecars() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![
Library::new("Filmy", LibraryKind::Movie),
Library::new("Mix", LibraryKind::Mixed),
Library::new("Seriály", LibraryKind::Series),
])
.unwrap();
let pool = fx.pool();
for rel in [
"Filmy/A (2000).mkv",
"Filmy/A (2000).nfo",
"Filmy/A (2000).cs.srt",
"Filmy/sub/B (2001).mkv",
"Mix/C (2002).mkv",
"Mix/Show (2010) [tmdbid-1]/Season 01/S01E01 - Show.mkv",
"Seriály/X/Season 01/S01E01 - X.mkv",
] {
fs::create_dir_all(pool.join(rel).parent().unwrap()).unwrap();
fs::write(pool.join(rel), "x").unwrap();
}
fx.fm.load_pool_movies().unwrap();
let names: Vec<String> = fx.fm.filelist.iter().map(|f| f.filename.clone()).collect();
assert_eq!(names, vec!["A (2000).mkv", "B (2001).mkv", "C (2002).mkv"]);
let series: Vec<String> = fx.fm.load_series().into_iter().map(|s| s.title).collect();
assert_eq!(series, vec!["Show", "X"]);
}
}