//! Pool-wide tools: consistency, statistics, duplicates, integrity, TMDb/ČSFD //! lookups, subtitles and the miniDLNA scope. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use super::{FileManager, LibraryKind, is_media_file}; use crate::core::csfd::{self, CsfdFilm}; use crate::core::error::CoreResult; use crate::core::file::File; use crate::core::integrity::{IntegrityResult, check_video_integrity, ffmpeg_available}; use crate::core::jellyfin::JellyfinMovie; use crate::core::migrate::{MatchProposal, MatchStatus, classify}; use crate::core::movie::identity_key_for; use crate::core::naming::{parse_pool_stem, parse_tmdb_id}; use crate::core::nfo::read_nfo; use crate::core::paths; use crate::core::pool_index::PoolIndex; use crate::core::series::Series; use crate::core::subtitles::{self, SubtitleFile}; use crate::core::tmdb::{self, TmdbResult}; #[derive(Debug, Clone, Default, PartialEq)] pub struct RenameAllReport { pub renamed: Vec<(String, String)>, pub skipped_no_year: Vec, pub conflicts: Vec<(String, String)>, pub unchanged: usize, pub cancelled: bool, } #[derive(Debug, Clone, Default, PartialEq)] pub struct ConsistencyReport { /// Indexed media files that no longer exist on disk. pub missing: Vec, /// Pool files (pool-relative) with no index entry. pub untracked: Vec, pub index_count: usize, pub disk_count: usize, } #[derive(Debug, Clone, Default, PartialEq)] pub struct JellyfinReport { pub matched: usize, pub pool_only: Vec, pub jellyfin_only: Vec, pub pool_total: usize, pub jellyfin_total: usize, } #[derive(Debug, Clone, Default, PartialEq)] pub struct IntegrityReport { pub problems: Vec, pub checked: usize, pub cancelled: bool, } #[derive(Debug, Clone, Default, PartialEq)] pub struct Stats { pub count: usize, pub total_size: u64, pub with_nfo: usize, pub without_nfo: usize, pub untagged: usize, pub avg_rating: Option, /// Category → `(tag, count)` sorted by frequency (descending). pub categories: BTreeMap>, } #[derive(Debug, Clone, Default, PartialEq)] pub struct DuplicateGroup { /// The primary version of each movie, path-sorted. pub movies: Vec, pub reasons: Vec, } #[derive(Debug, Clone, Default, PartialEq)] pub struct RefreshReport { pub updated: usize, pub skipped: usize, pub errors: Vec, } #[derive(Debug, Clone, Default, PartialEq)] pub struct ResolveResult { pub csfd_url: Option, pub film: Option, pub tmdb: Option, } /// Movies + mirrored libraries + mixed-library series for one miniDLNA run. #[derive(Debug, Clone, Default)] pub struct MinidlnaScope { pub movies: Vec, pub mirror_libs: Vec, pub mixed_series: Vec, } impl FileManager { /// Rename each movie to `Title (YYYY).ext`; reports conflicts without aborting. pub fn rename_all_to_canonical( &mut self, files: &[PathBuf], on_progress: &mut dyn FnMut(usize, usize), should_cancel: &dyn Fn() -> bool, ) -> RenameAllReport { let mut report = RenameAllReport::default(); for (i, path) in files.iter().enumerate() { if should_cancel() { report.cancelled = true; break; } let old_name = paths::name(path); match self.rename_to_canonical(path) { Err(e) => report.conflicts.push((old_name, e.to_string())), Ok(None) => report.skipped_no_year.push(old_name), Ok(Some(new)) if new != *path => report.renamed.push((old_name, paths::name(&new))), Ok(Some(_)) => report.unchanged += 1, } on_progress(i + 1, files.len()); } report } /// Compare the pool index against the actual files in the movie libraries. pub fn check_data_consistency(&self) -> CoreResult { let pool = self.require_pool()?; let opened; let index = match &self.index { Some(i) => i, None => { opened = PoolIndex::open(&pool); &opened } }; let index_keys: BTreeSet = index .records .values() .flat_map(|r| { r.get("media") .and_then(|m| m.as_array()) .cloned() .unwrap_or_default() }) .filter_map(|m| { m.get("file") .and_then(|f| f.as_str()) .filter(|s| !s.is_empty()) .map(String::from) }) .collect(); let mut disk_keys = BTreeSet::new(); for lib in self.libraries() { let lib_dir = pool.join(&lib.name); if !lib_dir.is_dir() { continue; } let files = match lib.kind { LibraryKind::Movie => paths::walk_files(&lib_dir), LibraryKind::Mixed => paths::entries(&lib_dir) .into_iter() .filter(|p| p.is_file()) .collect(), _ => continue, }; for f in files.into_iter().filter(|f| is_media_file(f)) { disk_keys.insert(paths::posix_relative(&f, &pool)); } } Ok(ConsistencyReport { missing: index_keys .iter() .filter(|k| !paths::join_posix(&pool, k).is_file()) .cloned() .collect(), untracked: disk_keys.difference(&index_keys).cloned().collect(), index_count: index_keys.len(), disk_count: disk_keys.len(), }) } /// Compare the pool against what Jellyfin sees (by TMDb id, else title+year). pub fn verify_against_jellyfin(&self, jf: &[JellyfinMovie]) -> JellyfinReport { let by_tmdb: HashMap = jf.iter().filter_map(|m| Some((m.tmdb_id?, m))).collect(); let by_name: HashMap<(String, Option), &JellyfinMovie> = jf .iter() .filter(|m| !m.title.is_empty()) .map(|m| ((m.title.trim().to_lowercase(), m.year), m)) .collect(); let mut matched_ids = HashSet::new(); let mut pool_only = Vec::new(); for f in &self.filelist { let tmdb_id = parse_tmdb_id(&paths::stem(&f.file_path)); let ctx = f.name_context(); let title = f .title .clone() .filter(|t| !t.is_empty()) .or_else(|| ctx.get("title").cloned()) .unwrap_or_default() .trim() .to_lowercase(); let year = ctx.get("year").and_then(|y| y.parse::().ok()); let hit = tmdb_id .and_then(|id| by_tmdb.get(&id)) .or_else(|| by_name.get(&(title, year))); match hit { Some(m) => { matched_ids.insert(m.jellyfin_id.clone()); } None => pool_only.push(f.filename.clone()), } } pool_only.sort(); let mut jellyfin_only: Vec = jf .iter() .filter(|m| !matched_ids.contains(&m.jellyfin_id)) .map(|m| m.title.clone()) .collect(); jellyfin_only.sort(); JellyfinReport { matched: self.filelist.len() - pool_only.len(), pool_only, jellyfin_only, pool_total: self.filelist.len(), jellyfin_total: jf.len(), } } /// Check `files` for corrupted video. Errors when the FFmpeg tool is missing. pub fn scan_video_integrity( files: &[PathBuf], deep: bool, on_progress: &mut dyn FnMut(usize, usize, &Path), should_cancel: &dyn Fn() -> bool, ) -> Result { if !ffmpeg_available(deep) { let tool = if deep { "ffmpeg" } else { "ffprobe" }; return Err(format!("{tool} není k dispozici (nainstaluj FFmpeg).")); } let mut report = IntegrityReport::default(); for (i, path) in files.iter().enumerate() { if should_cancel() { report.cancelled = true; break; } let result = check_video_integrity(path, deep, None); report.checked += 1; if !result.ok { report.problems.push(result); } on_progress(i + 1, files.len(), path); } Ok(report) } /// Aggregate stats over the loaded movies. pub fn statistics(&self) -> Stats { let mut stats = Stats { count: self.filelist.len(), ..Default::default() }; let mut ratings = Vec::new(); let mut cats: BTreeMap> = BTreeMap::new(); for f in &self.filelist { stats.total_size += fs::metadata(&f.file_path).map(|m| m.len()).unwrap_or(0); if f.meta.is_some() { stats.with_nfo += 1; } if f.tags.is_empty() { stats.untagged += 1; } for t in &f.tags { *cats .entry(t.category.clone()) .or_default() .entry(t.name.clone()) .or_default() += 1; } if let Some(r) = f.rating() { ratings.push(r); } } stats.without_nfo = stats.count - stats.with_nfo; if !ratings.is_empty() { let avg = ratings.iter().sum::() / ratings.len() as f64; stats.avg_rating = Some((avg * 10.0).round() / 10.0); } stats.categories = cats .into_iter() .map(|(c, counts)| { let mut v: Vec<(String, usize)> = counts.into_iter().collect(); v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); (c, v) }) .collect(); stats } /// `filelist` grouped per movie: one list per flat file / version folder. fn movie_units(&self) -> Vec> { let mut units: indexmap::IndexMap> = indexmap::IndexMap::new(); for f in &self.filelist { let anchor = if self.is_movie_group_member(&f.file_path) { f.file_path .parent() .map(Path::to_path_buf) .unwrap_or_default() } else { f.file_path.clone() }; units.entry(anchor).or_default().push(f.file_path.clone()); } units.into_values().collect() } /// Movies that show up in the pool more than once — same TMDb id, same /// title + year, or a byte-identical file size. Overlapping hits merge. pub fn find_duplicates(&self) -> Vec { let units = self.movie_units(); let reps: Vec = units.iter().map(|u| self.primary_version(u)).collect(); let mut parent: Vec = (0..units.len()).collect(); fn find(parent: &mut [usize], mut i: usize) -> usize { while parent[i] != i { parent[i] = parent[parent[i]]; i = parent[i]; } i } let mut reasons: Vec<(BTreeSet, String)> = Vec::new(); let mut flag = |parent: &mut Vec, idx: &[usize], reason: String| { if idx.len() < 2 { return; } for &i in &idx[1..] { let (a, b) = (find(parent, i), find(parent, idx[0])); parent[a] = b; } reasons.push((idx.iter().copied().collect(), reason)); }; let mut by_tmdb: indexmap::IndexMap> = indexmap::IndexMap::new(); let mut by_name: indexmap::IndexMap> = indexmap::IndexMap::new(); let mut by_size: indexmap::IndexMap> = indexmap::IndexMap::new(); let mut tmdb_of = Vec::new(); for (i, (unit, rep)) in units.iter().zip(&reps).enumerate() { let file = self.file(rep).cloned().unwrap_or_else(|| File::bare(rep)); let id = file.identity_from_path(); tmdb_of.push(id.tmdb_id); if let Some(t) = id.tmdb_id { by_tmdb.entry(t).or_default().push(i); } if let Some(y) = id.year.as_deref().filter(|_| !id.title.is_empty()) { by_name .entry(identity_key_for(None, &id.title, Some(y))) .or_default() .push(i); } for p in unit { let Ok(size) = fs::metadata(p).map(|m| m.len()) else { continue; }; let list = by_size.entry(size).or_default(); if size > 0 && !list.contains(&i) { list.push(i); } } } for (t, idx) in &by_tmdb { flag(&mut parent, idx, format!("stejné TMDb ID {t}")); } for idx in by_name.values() { let ids: HashSet> = idx.iter().map(|&i| tmdb_of[i]).collect(); if ids.len() == 1 && !ids.contains(&None) { continue; } flag(&mut parent, idx, "stejný název a rok".into()); } for idx in by_size.values() { flag(&mut parent, idx, "shodná velikost souboru".into()); } let mut groups: indexmap::IndexMap, BTreeSet)> = indexmap::IndexMap::new(); for (members, why) in reasons { let root = find(&mut parent, *members.iter().next().unwrap_or(&0)); let g = groups.entry(root).or_default(); g.0.extend(members); g.1.insert(why); } let mut result: Vec = groups .into_values() .map(|(idx, why)| { let mut movies: Vec = idx.into_iter().map(|i| reps[i].clone()).collect(); movies.sort_by_key(|p| p.to_string_lossy().to_lowercase()); DuplicateGroup { movies, reasons: why.into_iter().collect(), } }) .collect(); result.sort_by_key(|g| { let f = self.file(&g.movies[0]); f.and_then(|f| f.title.clone()) .filter(|t| !t.is_empty()) .unwrap_or_else(|| paths::name(&g.movies[0])) .to_lowercase() }); result } pub fn list_subtitles(&self, path: &Path) -> Vec { subtitles::find_subtitles(path) } pub fn attach_subtitle( &self, path: &Path, source: &Path, language: Option<&str>, flag: Option<&str>, move_source: bool, ) -> CoreResult { subtitles::attach_subtitle(path, source, language, flag, move_source) } pub fn remove_subtitle(&self, subtitle: &Path) -> CoreResult<()> { fs::remove_file(subtitle)?; Ok(()) } /// (Re)derive tags from TMDb by the tmdbid in each filename. pub fn refresh_tags_from_tmdb( &mut self, files: &[PathBuf], on_progress: &mut dyn FnMut(usize, usize), should_cancel: &dyn Fn() -> bool, ) -> Result { if !tmdb::api_available() { return Err("TMDB_API_KEY není nastaven (.env).".into()); } let schema = self.tag_schema(); let mut report = RefreshReport::default(); for (i, path) in files.iter().enumerate() { if should_cancel() { break; } match parse_tmdb_id(&paths::stem(path)) { None => report.skipped += 1, Some(id) => match tmdb::movie_details(id) { Ok(details) => { let r = self.with_file(path, |f, tm, index| { f.apply_tags_from(&details, &schema, tm, Some(index)) }); match r { Ok(_) => report.updated += 1, Err(e) => report.errors.push(format!("{}: {e}", paths::name(path))), } } Err(e) => report.errors.push(format!("{}: {e}", paths::name(path))), }, } on_progress(i + 1, files.len()); } Ok(report) } /// (Re)derive auto tags from each movie's NFO. `skipped` = without NFO. pub fn refresh_tags_from_nfo(&mut self, files: &[PathBuf]) -> CoreResult { let schema = self.tag_schema(); let mut report = RefreshReport::default(); for path in files { let Some(nfo) = read_nfo(path) else { report.skipped += 1; continue; }; self.with_file(path, |f, tm, index| { f.apply_tags_from(&nfo, &schema, tm, Some(index)) })?; report.updated += 1; } Ok(report) } /// ČSFD → TMDb bridge for one movie (best-effort: failures are `None`s). pub fn resolve_via_csfd( csfd_url: Option<&str>, query: Option<&str>, session: Option<&reqwest::blocking::Client>, ) -> ResolveResult { let mut result = ResolveResult { csfd_url: csfd_url.map(String::from), ..Default::default() }; let url = match csfd_url.filter(|u| !u.is_empty()) { Some(u) => Some(u.to_string()), None => query .filter(|q| !q.is_empty()) .and_then(|q| csfd::find_csfd_url(q, session).ok().flatten()), }; let Some(url) = url else { return result }; result.csfd_url = Some(url.clone()); let Ok(film) = csfd::fetch_film(&url, session) else { return result; }; let (title, year) = film.tmdb_query(); if !title.is_empty() && tmdb::api_available() { let mut hits = tmdb::search_movie(&title, year.as_deref(), 1).unwrap_or_default(); if hits.is_empty() && !film.czech_title.is_empty() && film.czech_title != title { hits = tmdb::search_movie(&film.czech_title, year.as_deref(), 1).unwrap_or_default(); } result.tmdb = hits.into_iter().next(); } result.film = Some(film); result } /// Propose a TMDb id for every movie without one (ČSFD-bridged when the /// movie carries a ČSFD link). pub fn match_pool_to_tmdb( &self, files: &[PathBuf], on_progress: &mut dyn FnMut(usize, usize), should_cancel: &dyn Fn() -> bool, ) -> Vec { let session = crate::core::http::new_session(); let mut proposals = Vec::new(); for (i, path) in files.iter().enumerate() { if should_cancel() { break; } if parse_tmdb_id(&paths::stem(path)).is_some() { on_progress(i + 1, files.len()); continue; } let file = self.file(path).cloned().unwrap_or_else(|| File::bare(path)); let ctx = file.name_context(); let mut title = file .title .clone() .filter(|t| !t.is_empty()) .or_else(|| ctx.get("title").cloned()) .unwrap_or_else(|| paths::stem(path)) .trim() .to_string(); if let Some((t, _)) = parse_pool_stem(&title) { title = t; } let mut year = ctx.get("year").cloned().filter(|y| !y.trim().is_empty()); let mut note = String::new(); if let Some(url) = &file.csfd_url && let Ok(film) = csfd::fetch_film(url, Some(&session)) { let ft = if film.original_title.is_empty() { film.czech_title.clone() } else { film.original_title.clone() }; if !ft.is_empty() { title = ft; year = film.year.clone().or(year); note = format!("přes ČSFD: {title}") + &film.year.map(|y| format!(" ({y})")).unwrap_or_default(); } } let key = file .movie_key .clone() .unwrap_or_else(|| file.identity_from_path().key); let proposal = match tmdb::search_movie(&title, year.as_deref(), 5) { Ok(candidates) => { let (status, chosen) = classify(&title, year.as_deref(), &candidates); MatchProposal { movie_key: key, current_name: file.filename.clone(), title, year, status, chosen, candidates, error: String::new(), note, } } Err(e) => MatchProposal { movie_key: key, current_name: file.filename.clone(), title, year, status: MatchStatus::Error, chosen: None, candidates: Vec::new(), error: e.to_string(), note, }, }; proposals.push(proposal); on_progress(i + 1, files.len()); } proposals } /// The pooled path whose filename is `name` (used to map proposals back). pub fn path_by_filename(&self, name: &str) -> Option { self.filelist .iter() .find(|f| f.filename == name) .map(|f| f.file_path.clone()) } /// What one miniDLNA run covers, narrowed to `minidlna_libraries` when set. pub fn minidlna_scope(&self) -> MinidlnaScope { let selected = self.minidlna_libraries(); let mut mixed: HashSet = self .libraries_of_kind(&[LibraryKind::Mixed]) .into_iter() .collect(); if let Some(sel) = &selected { mixed.retain(|m| sel.contains(m)); } let mixed_series = if mixed.is_empty() { Vec::new() } else { self.load_series() .into_iter() .filter(|s| mixed.contains(&s.library)) .collect() }; let mirrors = self.mirror_as_is_libraries(); match selected { None => MinidlnaScope { movies: self.filelist.clone(), mirror_libs: mirrors, mixed_series, }, Some(sel) => MinidlnaScope { movies: self .filelist .iter() .filter(|f| { self.library_of(&f.file_path) .is_some_and(|l| sel.contains(&l)) }) .cloned() .collect(), mirror_libs: mirrors.into_iter().filter(|m| sel.contains(m)).collect(), mixed_series, }, } } /// Mirrored output to remove for libraries that fell out of scope: /// `(stale library folders, valid children of the mixed-series bucket)`. pub fn minidlna_prune_targets( &self, mirror_libs: &[String], mixed_series: &[Series], bucket: &str, ) -> (Vec, HashSet) { let in_scope: HashSet<&String> = mirror_libs.iter().collect(); let stale = self .mirror_as_is_libraries() .into_iter() .filter(|n| !in_scope.contains(n) && n != bucket) .collect(); let mut valid: HashSet = mixed_series .iter() .map(|s| paths::name(&s.folder)) .collect(); if in_scope.contains(&bucket.to_string()) && let Some(pool) = self.pool_dir() { valid.extend( paths::subdirs(&pool.join(bucket)) .iter() .map(|p| paths::name(p)), ); } (stale, valid) } } #[cfg(test)] mod tests { use super::super::movies::ImportOptions; use super::super::test_support::Fixture; use super::super::{Library, LibraryKind}; use super::*; #[test] fn test_statistics_and_consistency() { let mut fx = Fixture::new(); let a = fx .fm .import_movie(&fx.source("a.mkv"), "A", ImportOptions::default()) .unwrap() .unwrap(); fx.fm .import_movie(&fx.source("bb.mkv"), "B", ImportOptions::default()) .unwrap(); fx.fm .assign_tag(std::slice::from_ref(&a), "Žánr/Drama") .unwrap(); let s = fx.fm.statistics(); assert_eq!((s.count, s.untagged, s.with_nfo), (2, 1, 0)); assert_eq!(s.total_size, 5 + 6); assert_eq!(s.categories["Žánr"], vec![("Drama".to_string(), 1)]); let c = fx.fm.check_data_consistency().unwrap(); assert!(c.missing.is_empty() && c.untracked.is_empty()); fs::remove_file(&a).unwrap(); fs::write(fx.pool().join("Filmy/stray.mkv"), "x").unwrap(); let c = fx.fm.check_data_consistency().unwrap(); assert_eq!(c.missing, vec!["Filmy/A.mkv"]); assert_eq!(c.untracked, vec!["Filmy/stray.mkv"]); } #[test] fn test_find_duplicates_cases() { let mut fx = Fixture::new(); let y = |y: &str, id: Option<&str>| ImportOptions { year: Some(y.into()), tmdb_id: id.map(String::from), ..Default::default() }; fx.fm .import_movie(&fx.source("a.mkv"), "Matrix", y("1999", Some("603"))) .unwrap(); fx.fm .import_movie( &fx.source("b.mkv"), "Matrix", ImportOptions { format_3d: Some("HSBS".into()), ..y("1999", Some("603")) }, ) .unwrap(); fx.fm .import_movie(&fx.source("cc.mkv"), "Pelíšky", y("1999", None)) .unwrap(); fx.fm .import_movie(&fx.source("dd.mkv"), "Pelisky", y("1999", Some("1"))) .unwrap(); fx.fm .import_movie(&fx.source("unique-size.mkv"), "Jiný", y("2000", None)) .unwrap(); let groups = fx.fm.find_duplicates(); assert_eq!(groups.len(), 2, "{groups:?}"); assert!( groups .iter() .any(|g| g.reasons.contains(&"stejné TMDb ID 603".to_string())) ); assert!( groups .iter() .any(|g| g.reasons.contains(&"stejný název a rok".to_string())) ); } #[test] fn test_versions_are_not_duplicates() { let mut fx = Fixture::new(); let opts = ImportOptions { year: Some("2013".into()), tmdb_id: Some("49051".into()), ..Default::default() }; let m = fx .fm .import_movie(&fx.source("h1.mkv"), "Hobit", opts) .unwrap() .unwrap(); fx.fm .add_movie_version(&m, &fx.source("h2.mkv"), "4K", false, None) .unwrap(); assert!(fx.fm.find_duplicates().is_empty()); } #[test] fn test_verify_against_jellyfin() { let mut fx = Fixture::new(); let opts = ImportOptions { year: Some("1999".into()), tmdb_id: Some("603".into()), ..Default::default() }; fx.fm .import_movie(&fx.source("a.mkv"), "Matrix", opts) .unwrap(); fx.fm .import_movie( &fx.source("b.mkv"), "Pelíšky", ImportOptions { year: Some("1999".into()), ..Default::default() }, ) .unwrap(); fx.fm .import_movie(&fx.source("c.mkv"), "Chybí", ImportOptions::default()) .unwrap(); let jm = |id: &str, t: &str, y: Option, tmdb: Option| JellyfinMovie { jellyfin_id: id.into(), title: t.into(), year: y, tmdb_id: tmdb, path: None, }; let r = fx.fm.verify_against_jellyfin(&[ jm("1", "The Matrix", Some(1999), Some(603)), jm("2", "Pelíšky", Some(1999), None), jm("3", "Navíc", None, None), ]); assert_eq!(r.matched, 2); assert_eq!(r.pool_only, vec!["Chybí.mkv"]); assert_eq!(r.jellyfin_only, vec!["Navíc"]); } #[test] fn test_minidlna_scope_and_prune() { let mut fx = Fixture::new(); fx.fm .set_libraries(vec![ Library::new("Filmy", LibraryKind::Movie), Library::new("Kids", LibraryKind::Movie), Library::new("Seriály", LibraryKind::Series), Library::new("Domácí", LibraryKind::HomeVideo), Library::new("Mix", LibraryKind::Mixed), ]) .unwrap(); fx.fm .import_movie(&fx.source("a.mkv"), "A", ImportOptions::default()) .unwrap(); fx.fm .import_movie( &fx.source("b.mkv"), "B", ImportOptions { library: Some("Kids".into()), ..Default::default() }, ) .unwrap(); let ep = fx .pool() .join("Mix/Show (2010)/Season 01/S01E01 - Show.mkv"); fs::create_dir_all(ep.parent().unwrap()).unwrap(); fs::write(&ep, "e").unwrap(); let all = fx.fm.minidlna_scope(); assert_eq!(all.movies.len(), 2); assert_eq!(all.mirror_libs, vec!["Seriály", "Domácí"]); assert_eq!(all.mixed_series.len(), 1); fx.fm .set_minidlna_libraries(Some(vec!["Filmy".into(), "Seriály".into()])) .unwrap(); let some = fx.fm.minidlna_scope(); assert_eq!(some.movies.len(), 1); assert_eq!(some.mirror_libs, vec!["Seriály"]); assert!(some.mixed_series.is_empty()); fs::create_dir_all(fx.pool().join("Seriály/Real")).unwrap(); let (stale, valid) = fx.fm .minidlna_prune_targets(&some.mirror_libs, &all.mixed_series, "Seriály"); assert_eq!(stale, vec!["Domácí"]); assert!(valid.contains("Real") && valid.contains("Show (2010)")); let (stale2, valid2) = fx.fm.minidlna_prune_targets(&[], &[], "Seriály"); assert_eq!(stale2, vec!["Domácí"]); assert!(valid2.is_empty()); } #[test] fn test_refresh_tags_from_nfo() { let mut fx = Fixture::new(); let opts = ImportOptions { year: Some("1999".into()), tmdb_id: Some("603".into()), ..Default::default() }; let p = fx .fm .import_movie(&fx.source("raw.mkv"), "Matrix", opts) .unwrap() .unwrap(); fx.fm .import_movie(&fx.source("other.mkv"), "Other", ImportOptions::default()) .unwrap(); fs::write(p.with_extension("nfo"), "1999AkčníSci-FiUSA8.7").unwrap(); let files: Vec = fx.fm.filelist.iter().map(|f| f.file_path.clone()).collect(); let r = fx.fm.refresh_tags_from_nfo(&files).unwrap(); assert_eq!((r.updated, r.skipped), (1, 1)); let f = fx.fm.file(&p).unwrap(); for t in ["Žánr/Akční", "Žánr/Sci-Fi", "Rok/1999", "Země původu/USA"] { assert!(f.tag_paths().contains(t), "{t}"); } assert_eq!(f.meta.as_ref().unwrap()["rating"], 87); } #[test] fn test_rename_all_to_canonical_reports() { let mut fx = Fixture::new(); let a = fx .fm .import_movie(&fx.source("a.mkv"), "A", ImportOptions::default()) .unwrap() .unwrap(); let b = fx .fm .import_movie(&fx.source("b.mkv"), "B", ImportOptions::default()) .unwrap() .unwrap(); fx.fm .assign_tag(std::slice::from_ref(&a), "Rok/2000") .unwrap(); let r = fx .fm .rename_all_to_canonical(&[a, b], &mut |_, _| {}, &|| false); assert_eq!( r.renamed, vec![("A.mkv".to_string(), "A (2000).mkv".to_string())] ); assert_eq!(r.skipped_no_year, vec!["B.mkv"]); } #[test] fn test_subtitle_attach_and_remove() { let mut fx = Fixture::new(); let p = fx .fm .import_movie(&fx.source("a.mkv"), "A", ImportOptions::default()) .unwrap() .unwrap(); let sub = fx.source("x.srt"); let target = fx .fm .attach_subtitle(&p, &sub, Some("cs"), None, false) .unwrap(); assert_eq!(fx.fm.list_subtitles(&p).len(), 1); fx.fm.remove_subtitle(&target).unwrap(); assert!(fx.fm.list_subtitles(&p).is_empty()); } }