//! `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 { 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) -> Vec { 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> { 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, pub tagmanager: TagManager, pub global_config: GlobalConfig, /// Unified pool metadata + library settings. pub index: Option, 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 { self.global_config .pool_dir .as_deref() .filter(|s| !s.is_empty()) .map(PathBuf::from) } pub fn filmoteka_dir(&self) -> Option { self.global_config .filmoteka_dir .as_deref() .filter(|s| !s.is_empty()) .map(PathBuf::from) } pub(crate) fn require_pool(&self) -> CoreResult { 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 { let raw: Vec = 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) -> 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 { self.libraries() .into_iter() .filter(|l| kinds.contains(&l.kind)) .map(|l| l.name) .collect() } pub fn library_kind(&self, name: &str) -> Option { 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 { 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 { 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 { self.libraries_of_kind(&[LibraryKind::Series, LibraryKind::Mixed]) } /// The series (copy-as-is) libraries. pub fn copyasis_folders(&self) -> Vec { self.libraries_of_kind(&[LibraryKind::Series]) } /// Libraries hardcopied 1:1 into miniDLNA — series and homevideo. pub fn mirror_as_is_libraries(&self) -> Vec { 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> { let raw = str_list(self.setting("minidlna_libraries"))?; let known: HashSet = 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>) -> 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 = 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 { 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 { 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 { 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 { 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( &mut self, path: &Path, op: impl FnOnce(&mut File, &mut TagManager, &mut PoolIndex) -> CoreResult, ) -> CoreResult { 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) -> 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 (`/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 = 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 = ["Žá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 = 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 = fx.fm.load_series().into_iter().map(|s| s.title).collect(); assert_eq!(series, vec!["Show", "X"]); } }