950 lines
34 KiB
Rust
950 lines
34 KiB
Rust
//! 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<String>,
|
|
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<String>,
|
|
/// Pool files (pool-relative) with no index entry.
|
|
pub untracked: Vec<String>,
|
|
pub index_count: usize,
|
|
pub disk_count: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct JellyfinReport {
|
|
pub matched: usize,
|
|
pub pool_only: Vec<String>,
|
|
pub jellyfin_only: Vec<String>,
|
|
pub pool_total: usize,
|
|
pub jellyfin_total: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct IntegrityReport {
|
|
pub problems: Vec<IntegrityResult>,
|
|
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<f64>,
|
|
/// Category → `(tag, count)` sorted by frequency (descending).
|
|
pub categories: BTreeMap<String, Vec<(String, usize)>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct DuplicateGroup {
|
|
/// The primary version of each movie, path-sorted.
|
|
pub movies: Vec<PathBuf>,
|
|
pub reasons: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct RefreshReport {
|
|
pub updated: usize,
|
|
pub skipped: usize,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq)]
|
|
pub struct ResolveResult {
|
|
pub csfd_url: Option<String>,
|
|
pub film: Option<CsfdFilm>,
|
|
pub tmdb: Option<TmdbResult>,
|
|
}
|
|
|
|
/// Movies + mirrored libraries + mixed-library series for one miniDLNA run.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct MinidlnaScope {
|
|
pub movies: Vec<File>,
|
|
pub mirror_libs: Vec<String>,
|
|
pub mixed_series: Vec<Series>,
|
|
}
|
|
|
|
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<ConsistencyReport> {
|
|
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<String> = 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<i64, &JellyfinMovie> =
|
|
jf.iter().filter_map(|m| Some((m.tmdb_id?, m))).collect();
|
|
let by_name: HashMap<(String, Option<i64>), &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::<i64>().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<String> = 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<IntegrityReport, String> {
|
|
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<String, HashMap<String, usize>> = 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::<f64>() / 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<Vec<PathBuf>> {
|
|
let mut units: indexmap::IndexMap<PathBuf, Vec<PathBuf>> = 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<DuplicateGroup> {
|
|
let units = self.movie_units();
|
|
let reps: Vec<PathBuf> = units.iter().map(|u| self.primary_version(u)).collect();
|
|
let mut parent: Vec<usize> = (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<usize>, String)> = Vec::new();
|
|
let mut flag = |parent: &mut Vec<usize>, 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<i64, Vec<usize>> = indexmap::IndexMap::new();
|
|
let mut by_name: indexmap::IndexMap<String, Vec<usize>> = indexmap::IndexMap::new();
|
|
let mut by_size: indexmap::IndexMap<u64, Vec<usize>> = 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<Option<i64>> = 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<usize, (BTreeSet<usize>, BTreeSet<String>)> =
|
|
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<DuplicateGroup> = groups
|
|
.into_values()
|
|
.map(|(idx, why)| {
|
|
let mut movies: Vec<PathBuf> = 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<SubtitleFile> {
|
|
subtitles::find_subtitles(path)
|
|
}
|
|
|
|
pub fn attach_subtitle(
|
|
&self,
|
|
path: &Path,
|
|
source: &Path,
|
|
language: Option<&str>,
|
|
flag: Option<&str>,
|
|
move_source: bool,
|
|
) -> CoreResult<PathBuf> {
|
|
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<RefreshReport, String> {
|
|
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<RefreshReport> {
|
|
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<MatchProposal> {
|
|
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<PathBuf> {
|
|
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<String> = 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<String>, HashSet<String>) {
|
|
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<String> = 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<i64>, tmdb: Option<i64>| 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"), "<movie><year>1999</year><genre>Akční</genre><genre>Sci-Fi</genre><country>USA</country><rating>8.7</rating></movie>").unwrap();
|
|
let files: Vec<PathBuf> = 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());
|
|
}
|
|
}
|