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
+998
View File
@@ -0,0 +1,998 @@
//! Movie versions and parts (Jellyfin "multiple versions" and "stacking").
//!
//! A movie with one video file stays flat (`<Library>/Title (rok) [tmdbid].ext`).
//! The moment a second file is added it is promoted to its own folder, where
//! every file starts with the folder's exact name:
//!
//! ```text
//! <Library>/Title (rok) [tmdbid]/
//! Title (rok) [tmdbid].ext (default version, bare name)
//! Title (rok) [tmdbid] - 3D HSBS.ext (another version)
//! ```
//!
//! or, for a film split across files, `Title (rok) [tmdbid]-part1.ext`, `-part2`…
//! A folder holds either versions or parts, never both.
use std::fs;
use std::path::{Path, PathBuf};
use super::{FileManager, LibraryKind, is_media_file};
use crate::core::error::{CoreError, CoreResult};
use crate::core::file::{File, now_iso};
use crate::core::movie::{
DEFAULT_VERSION_LABEL, normalize_3d_label, normalize_part_label, part_label,
};
use crate::core::naming::{is_canonical_pool_stem, parse_3d_format};
use crate::core::paths;
use crate::core::series::looks_like_series_folder;
use crate::core::subtitles;
const PARTS_AND_VERSIONS_MIX: &str = "Film může mít buď díly (přehrají se za sebou), nebo verze (alternativy jako 2D/3D, 4K) — ne obojí v jedné složce. Uprav popisek, nebo nejdřív odeber soubory toho druhého druhu.";
/// How a film's files are laid out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GroupMode {
/// One file in the library root.
Flat,
/// A folder of alternatives.
Versions,
/// A folder of pieces played back to back.
Parts,
}
/// Filename of one version in a movie folder: the default version is bare
/// `<stem><ext>`, a part `<stem>-part<N><ext>`, anything else
/// `<stem> - <label><ext>`.
pub fn version_filename(stem: &str, label: &str, ext: &str) -> String {
let label = label.trim();
if label.is_empty() || label == DEFAULT_VERSION_LABEL {
return format!("{stem}{ext}");
}
match normalize_part_label(label) {
Some(part) => format!("{stem}-part{part}{ext}"),
None => format!("{stem} - {label}{ext}"),
}
}
/// True when `labels` contain both part labels and version labels.
fn mixes_parts_and_versions<'a>(labels: impl IntoIterator<Item = &'a str>) -> bool {
let kinds: std::collections::HashSet<bool> = labels
.into_iter()
.map(|l| normalize_part_label(l).is_some())
.collect();
kinds.len() > 1
}
/// The version label a pooled path implies.
pub fn label_of(path: &Path) -> String {
File::bare(path).identity_from_path().label
}
fn label_or_default(path: &Path) -> String {
let l = label_of(path);
if l.is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
l
}
}
impl FileManager {
/// True when `path` is one version inside its own per-movie folder (in a
/// movie or mixed library; in a mixed one the folder must not be a series).
pub fn is_movie_group_member(&self, path: &Path) -> bool {
let (Some(pool), Some(lib)) = (self.pool_dir(), self.library_of(path)) else {
return false;
};
if !self.libraries_accepting_movies().contains(&lib) {
return false;
}
let Some(parent) = path.parent() else {
return false;
};
if parent == pool.join(&lib) || !is_canonical_pool_stem(&paths::name(parent)) {
return false;
}
!(self.library_kind(&lib) == Some(LibraryKind::Mixed) && looks_like_series_folder(parent))
}
/// Every version sharing `path`'s movie folder, name-sorted (`[path]` when flat).
pub fn list_movie_versions(&self, path: &Path) -> Vec<PathBuf> {
if !self.is_movie_group_member(path) {
return vec![path.to_path_buf()];
}
let folder = path.parent().unwrap_or(Path::new("."));
paths::entries(folder)
.into_iter()
.filter(|p| p.is_file() && is_media_file(p))
.collect()
}
/// The version shown for the movie in the table: plain file first, then the
/// "Výchozí" one, then alphabetically.
pub fn primary_version(&self, versions: &[PathBuf]) -> PathBuf {
versions
.iter()
.min_by_key(|p| {
let label = label_of(p);
let order = if label.is_empty() {
0
} else if label == DEFAULT_VERSION_LABEL {
1
} else {
2
};
(order, paths::name(p).to_lowercase())
})
.cloned()
.unwrap_or_default()
}
/// `filelist` collapsed to one row per movie (a folder contributes only its
/// primary version).
pub fn visible_movies(&self) -> Vec<PathBuf> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for f in &self.filelist {
if !self.is_movie_group_member(&f.file_path) {
out.push(f.file_path.clone());
continue;
}
let folder = f
.file_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
if seen.insert(folder) {
out.push(self.primary_version(&self.list_movie_versions(&f.file_path)));
}
}
out
}
/// Add `source` as another playable version of `path`'s movie, promoting a
/// flat movie to its own folder on the first extra version. An empty
/// `label` becomes "Výchozí"; an unset `existing_label` is derived from the
/// existing file (its 3D tag, else "Výchozí", or `part1` when adding parts).
pub fn add_movie_version(
&mut self,
path: &Path,
source: &Path,
label: &str,
move_source: bool,
existing_label: Option<&str>,
) -> CoreResult<PathBuf> {
let label = if label.trim().is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
label.trim().to_string()
};
let pool = self.require_pool()?;
let (folder, stem) = if self.is_movie_group_member(path) {
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let existing: Vec<String> = self
.list_movie_versions(path)
.iter()
.map(|p| label_or_default(p))
.collect();
if mixes_parts_and_versions(existing.iter().map(String::as_str).chain([label.as_str()]))
{
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let stem = paths::name(&folder);
(folder, stem)
} else {
let file = self.file(path).cloned().unwrap_or_else(|| File::bare(path));
let stem = self.canonical_name_for(&file, false).ok_or_else(|| {
CoreError::invalid("Film nemá platný název/rok — nejde založit složku verzí.")
})?;
let existing_label = match existing_label {
Some(l) => l.to_string(),
None if normalize_part_label(&label).is_some() => "part1".into(),
None => parse_3d_format(&paths::stem(path))
.map_or_else(|| DEFAULT_VERSION_LABEL.to_string(), |f| format!("3D {f}")),
};
if mixes_parts_and_versions([existing_label.as_str(), label.as_str()]) {
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let lib = self
.library_of(path)
.unwrap_or_else(|| self.movie_libraries()[0].clone());
let folder = pool.join(lib).join(&stem);
if folder.exists() {
return Err(CoreError::Exists(format!(
"Složka „{stem}“ už v poolu existuje."
)));
}
fs::create_dir_all(&folder)?;
let existing_target = folder.join(version_filename(
&stem,
&existing_label,
&paths::suffix(path),
));
paths::move_file(path, &existing_target)?;
subtitles::rename_subtitles_with(path, &existing_target)?;
self.with_file(path, |f, tm, index| {
f.relocate(&existing_target, Some(index))?;
f.save_location(tm, index)
})?;
(folder, stem)
};
let target = folder.join(version_filename(&stem, &label, &paths::suffix(source)));
if target.exists() {
return Err(CoreError::Exists(format!("Verze „{label}“ už existuje.")));
}
paths::transfer(source, &target, move_source)?;
let Self {
tagmanager,
index,
filelist,
..
} = self;
let index = index
.as_mut()
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
let mut version = File::load(&target, tagmanager, Some(index))?;
version.added = Some(now_iso());
version.save_metadata(index)?;
filelist.push(version);
Ok(target)
}
/// Add `source` as the next part of `path`'s film (a flat film becomes
/// `part1` and `source` `part2`).
pub fn add_movie_part(
&mut self,
path: &Path,
source: &Path,
move_source: bool,
) -> CoreResult<PathBuf> {
if !self.is_movie_group_member(path) {
return self.add_movie_version(path, source, &part_label(2), move_source, None);
}
let numbers: Vec<Option<u32>> = self
.list_movie_versions(path)
.iter()
.map(|p| normalize_part_label(&label_of(p)))
.collect();
if numbers.iter().any(Option::is_none) {
return Err(CoreError::invalid(
"Film už má verze (2D/3D, kvalita…) — díly s verzemi zatím nejde kombinovat. Odeber nejdřív verze, nebo přidej soubor jako verzi.",
));
}
let next = numbers.into_iter().flatten().max().unwrap_or(0) + 1;
self.add_movie_version(path, source, &part_label(next), move_source, None)
}
pub fn movie_group_mode(&self, path: &Path) -> GroupMode {
if !self.is_movie_group_member(path) {
return GroupMode::Flat;
}
let labels: Vec<String> = self
.list_movie_versions(path)
.iter()
.map(|p| label_of(p))
.collect();
if !labels.is_empty() && labels.iter().all(|l| normalize_part_label(l).is_some()) {
GroupMode::Parts
} else {
GroupMode::Versions
}
}
/// The film's files in display order — parts by number, versions with the
/// default first, then by popisek.
pub fn ordered_media(&self, path: &Path) -> Vec<PathBuf> {
let mut versions = self.list_movie_versions(path);
versions.sort_by_key(|p| {
let label = label_of(p);
match normalize_part_label(&label) {
Some(n) => (0, n, String::new()),
None if label.is_empty() || label == DEFAULT_VERSION_LABEL => (0, 0, String::new()),
None => (1, 0, label.to_lowercase()),
}
});
versions
}
/// Give every file of the folder a new popisek at once (through temporary
/// names, so swapping two labels never overwrites a file).
fn relabel_movie_group(
&mut self,
path: &Path,
assignments: &[(PathBuf, String)],
) -> CoreResult<()> {
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let current: std::collections::HashSet<PathBuf> =
self.list_movie_versions(path).into_iter().collect();
let assigned: std::collections::HashSet<PathBuf> =
assignments.iter().map(|(p, _)| p.clone()).collect();
if assigned != current || assignments.len() != current.len() {
return Err(CoreError::invalid(
"Přeřazení musí zahrnout každý soubor složky právě jednou.",
));
}
let labels: Vec<String> = assignments
.iter()
.map(|(_, l)| {
if l.trim().is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
l.trim().to_string()
}
})
.collect();
if mixes_parts_and_versions(labels.iter().map(String::as_str)) {
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let stem = paths::name(&folder);
let targets: Vec<PathBuf> = assignments
.iter()
.zip(&labels)
.map(|((p, _), l)| folder.join(version_filename(&stem, l, &paths::suffix(p))))
.collect();
let unique: std::collections::HashSet<String> = targets
.iter()
.map(|t| paths::name(t).to_lowercase())
.collect();
if unique.len() != targets.len() {
return Err(CoreError::invalid(
"Dva soubory by dostaly stejný název — popisky se musí lišit.",
));
}
let mut temps = Vec::new();
for (i, (p, _)) in assignments.iter().enumerate() {
let temp = folder.join(format!("{stem} - .curator-tmp-{i}{}", paths::suffix(p)));
self.relocate_file(p, &temp, true)?;
temps.push(temp);
}
for (temp, target) in temps.iter().zip(&targets) {
self.relocate_file(temp, target, true)?;
self.with_file(target, |f, tm, index| f.save_location(tm, index))?;
}
Ok(())
}
/// Move one part earlier (`delta < 0`) or later; parts are renumbered.
pub fn move_movie_part(&mut self, path: &Path, delta: i32) -> CoreResult<()> {
if self.movie_group_mode(path) != GroupMode::Parts {
return Err(CoreError::invalid("Film nemá díly, které by šlo přeřadit."));
}
let mut order = self.ordered_media(path);
let Some(i) = order.iter().position(|p| p == path) else {
return Ok(());
};
let j = i as i64 + i64::from(delta);
if j < 0 || j >= order.len() as i64 {
return Ok(());
}
order.swap(i, j as usize);
let assignments: Vec<(PathBuf, String)> = order
.into_iter()
.enumerate()
.map(|(n, p)| (p, part_label(n as u32 + 1)))
.collect();
self.relabel_movie_group(path, &assignments)
}
/// Switch a folder between versions and parts. To versions, the first part
/// becomes the default and the others take `labels` in order.
pub fn set_movie_group_mode(
&mut self,
path: &Path,
mode: GroupMode,
labels: &[String],
) -> CoreResult<()> {
if mode == GroupMode::Flat {
return Err(CoreError::invalid("Neznámý režim „flat“."));
}
let current = self.movie_group_mode(path);
if current == GroupMode::Flat {
return Err(CoreError::invalid(
"Film má jen jeden soubor — režim složky zatím nemá co měnit.",
));
}
if current == mode {
return Ok(());
}
let order = self.ordered_media(path);
if mode == GroupMode::Parts {
let a: Vec<(PathBuf, String)> = order
.into_iter()
.enumerate()
.map(|(n, p)| (p, part_label(n as u32 + 1)))
.collect();
return self.relabel_movie_group(path, &a);
}
let clean: Vec<String> = labels.iter().map(|l| l.trim().to_string()).collect();
if clean.len() != order.len() - 1 || clean.iter().any(String::is_empty) {
return Err(CoreError::invalid(format!(
"Každý další soubor potřebuje popisek verze ({}).",
order.len() - 1
)));
}
if clean
.iter()
.any(|l| l.to_lowercase() == DEFAULT_VERSION_LABEL.to_lowercase())
{
return Err(CoreError::invalid(format!(
"„{DEFAULT_VERSION_LABEL}“ je vyhrazený pro první soubor."
)));
}
let unique: std::collections::HashSet<String> =
clean.iter().map(|l| l.to_lowercase()).collect();
if unique.len() != clean.len() {
return Err(CoreError::invalid("Popisky verzí se musí lišit."));
}
let mut a = vec![(order[0].clone(), String::new())];
a.extend(order[1..].iter().cloned().zip(clean));
self.relabel_movie_group(path, &a)
}
/// Rename one version's label within its folder (empty → "Výchozí").
pub fn rename_movie_version(&mut self, path: &Path, new_label: &str) -> CoreResult<PathBuf> {
if !self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film nemá žádné další verze — popisek se uplatní až od druhé verze.",
));
}
let new_label = if new_label.trim().is_empty() {
DEFAULT_VERSION_LABEL.to_string()
} else {
new_label.trim().to_string()
};
let others: Vec<String> = self
.list_movie_versions(path)
.iter()
.filter(|p| p.as_path() != path)
.map(|p| label_or_default(p))
.collect();
if mixes_parts_and_versions(
others
.iter()
.map(String::as_str)
.chain([new_label.as_str()]),
) {
return Err(CoreError::invalid(PARTS_AND_VERSIONS_MIX));
}
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let target = folder.join(version_filename(
&paths::name(&folder),
&new_label,
&paths::suffix(path),
));
if target == path {
return Ok(target);
}
if target.exists() {
return Err(CoreError::Exists(format!(
"Verze „{new_label}“ už existuje."
)));
}
self.relocate_file(path, &target, true)?;
self.with_file(&target, |f, tm, index| f.save_location(tm, index))?;
Ok(target)
}
/// Rename grouped default versions still called `<folder> - Výchozí.ext` to
/// the bare `<folder>.ext`. Returns the `(old, new)` names.
pub fn normalize_default_version_names(&mut self) -> CoreResult<Vec<(String, String)>> {
let mut renamed = Vec::new();
let paths_now: Vec<PathBuf> = self.filelist.iter().map(|f| f.file_path.clone()).collect();
for path in paths_now {
if !self.is_movie_group_member(&path) {
continue;
}
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
let fname = paths::name(&folder);
if paths::stem(&path) != format!("{fname} - {DEFAULT_VERSION_LABEL}") {
continue;
}
let target = folder.join(format!("{fname}{}", paths::suffix(&path)));
if target.exists() {
continue;
}
self.relocate_file(&path, &target, true)?;
self.with_file(&target, |f, tm, index| f.save_location(tm, index))?;
renamed.push((paths::name(&path), paths::name(&target)));
}
Ok(renamed)
}
/// Remove one version (+ subtitles). When one version is left the movie is
/// demoted back to a flat file (keeping a 3D tag if the survivor was 3D).
pub fn remove_movie_version(&mut self, path: &Path) -> CoreResult<()> {
if !self.is_movie_group_member(path) {
return Err(CoreError::invalid(
"Film nemá žádné další verze k odebrání.",
));
}
let folder = path.parent().map(Path::to_path_buf).unwrap_or_default();
self.with_file(path, |f, _, index| f.delete_metadata(index))?;
for sub in subtitles::find_subtitles(path) {
fs::remove_file(&sub.path)?;
}
fs::remove_file(path)?;
self.filelist.retain(|f| f.file_path != path);
let remaining: Vec<PathBuf> = paths::entries(&folder)
.into_iter()
.filter(|p| p.is_file() && is_media_file(p))
.collect();
if remaining.len() == 1 {
let sole = &remaining[0];
let fname = paths::name(&folder);
let prefix = format!("{fname} - ");
let sole_stem = paths::stem(sole);
let label = sole_stem.strip_prefix(&prefix).unwrap_or("");
let new_stem = match normalize_3d_label(label) {
Some(fmt) => format!("{fname}.3D.{fmt}"),
None => fname.clone(),
};
let new_path = folder
.parent()
.unwrap_or(Path::new("."))
.join(format!("{new_stem}{}", paths::suffix(sole)));
self.relocate_file(sole, &new_path, true)?;
self.with_file(&new_path, |f, tm, index| f.save_location(tm, index))?;
let _ = fs::remove_dir_all(&folder);
}
Ok(())
}
/// Delete a whole movie — every version, subtitle and record.
pub fn remove_movie(&mut self, path: &Path) -> CoreResult<()> {
let versions = self.list_movie_versions(path);
let grouped = self.is_movie_group_member(path);
for v in &versions {
self.with_file(v, |f, _, index| f.delete_metadata(index))?;
for sub in subtitles::find_subtitles(v) {
let _ = fs::remove_file(&sub.path);
}
let _ = fs::remove_file(v);
self.filelist.retain(|f| &f.file_path != v);
}
if grouped && let Some(folder) = path.parent() {
let _ = fs::remove_dir_all(folder);
}
Ok(())
}
/// Fold the duplicate movie `other` into `keep` as one more version. `keep`'s
/// metadata wins; `other` only contributes missing tags and a ČSFD link.
pub fn merge_as_version(
&mut self,
keep: &Path,
other: &Path,
label: &str,
keep_label: Option<&str>,
) -> CoreResult<PathBuf> {
if keep == other {
return Err(CoreError::invalid("Nelze sloučit film sám se sebou."));
}
if self.is_movie_group_member(other) {
return Err(CoreError::invalid(
"Slučovaný film už má více verzí — slouč opačně, nebo verze nejdřív odeber.",
));
}
if self.is_movie_group_member(keep) && keep.parent() == other.parent() {
return Err(CoreError::invalid(
"Oba soubory už jsou verzemi téhož filmu.",
));
}
let other_file = self
.file(other)
.cloned()
.unwrap_or_else(|| File::bare(other));
self.with_file(keep, |k, _, index| {
for tag in &other_file.tags {
if !k.tags.contains(tag) {
k.tags.push(tag.clone());
if other_file.auto_tag_paths.contains(&tag.full_path()) {
k.auto_tag_paths.insert(tag.full_path());
}
}
}
if k.csfd_url.is_none() && other_file.csfd_url.is_some() {
k.csfd_url = other_file.csfd_url.clone();
}
k.save_metadata(index)
})?;
self.with_file(other, |f, _, index| f.delete_metadata(index))?;
let new_version = match self.add_movie_version(keep, other, label, true, keep_label) {
Ok(p) => p,
Err(e) => {
let _ = self.with_file(other, |f, _, index| f.save_metadata(index));
return Err(e);
}
};
subtitles::rename_subtitles_with(other, &new_version)?;
self.filelist.retain(|f| f.file_path != other);
Ok(new_version)
}
}
#[cfg(test)]
mod tests {
use super::super::movies::ImportOptions;
use super::super::test_support::Fixture;
use super::*;
use crate::core::file_manager::Library;
const STEM: &str = "Hobit (2013) [tmdbid-49051]";
fn hobbit(fx: &mut Fixture) -> PathBuf {
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
..Default::default()
};
fx.fm
.import_movie(&fx.source("h1.mkv"), "Hobit", opts)
.unwrap()
.unwrap()
}
fn folder(fx: &Fixture) -> PathBuf {
fx.pool().join("Filmy").join(STEM)
}
#[test]
fn test_version_filename_variants() {
assert_eq!(version_filename("S", "", ".mkv"), "S.mkv");
assert_eq!(
version_filename("S", DEFAULT_VERSION_LABEL, ".mkv"),
"S.mkv"
);
assert_eq!(version_filename("S", "CD2", ".mkv"), "S-part2.mkv");
assert_eq!(version_filename("S", "3D HSBS", ".mkv"), "S - 3D HSBS.mkv");
}
#[test]
fn test_add_version_promotes_flat_movie_with_subtitles() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
assert!(!fx.fm.is_movie_group_member(&movie));
fs::write(fx.pool().join(format!("Filmy/{STEM}.cs.srt")), "s").unwrap();
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "3D HSBS", false, None)
.unwrap();
assert_eq!(v2, folder(&fx).join(format!("{STEM} - 3D HSBS.mkv")));
let promoted = folder(&fx).join(format!("{STEM}.mkv"));
assert!(promoted.exists());
assert!(folder(&fx).join(format!("{STEM}.cs.srt")).exists());
assert!(fx.fm.is_movie_group_member(&promoted));
assert_eq!(fx.fm.list_movie_versions(&promoted).len(), 2);
assert_eq!(fx.fm.visible_movies(), vec![promoted.clone()]);
assert!(matches!(
fx.fm
.add_movie_version(&promoted, &fx.source("h3.mkv"), "3D HSBS", false, None),
Err(CoreError::Exists(_))
));
// empty label → Výchozí → bare name, already taken
assert!(
fx.fm
.add_movie_version(&promoted, &fx.source("h4.mkv"), "", false, None)
.is_err()
);
}
#[test]
fn test_import_existing_film_adds_version_or_refuses() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "3D HSBS", false, None)
.unwrap();
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
on_conflict: super::super::OnConflict::Version,
version_label: Some("4K".into()),
..Default::default()
};
let p = fx
.fm
.import_movie(&fx.source("h5.mkv"), "Hobit", opts.clone())
.unwrap()
.unwrap();
assert_eq!(p, folder(&fx).join(format!("{STEM} - 4K.mkv")));
let plain = ImportOptions {
version_label: None,
..opts.clone()
};
assert!(matches!(
fx.fm.import_movie(&fx.source("h8.mkv"), "Hobit", plain),
Err(CoreError::Exists(_))
));
let ftab = ImportOptions {
version_label: None,
format_3d: Some("FTAB".into()),
..opts
};
let p3 = fx
.fm
.import_movie(&fx.source("h6.mkv"), "Hobit", ftab)
.unwrap()
.unwrap();
assert_eq!(paths::name(&p3), format!("{STEM} - 3D FTAB.mkv"));
let root_files: Vec<PathBuf> = paths::entries(&fx.pool().join("Filmy"))
.into_iter()
.filter(|p| p.is_file())
.collect();
assert!(root_files.is_empty());
}
#[test]
fn test_remove_version_demotes_and_keeps_3d_and_metadata() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.assign_tag(std::slice::from_ref(&movie), "Žánr/Fantasy")
.unwrap();
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "3D HSBS", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
fx.fm.remove_movie_version(&primary).unwrap();
let flat = fx.pool().join(format!("Filmy/{STEM}.3D.HSBS.mkv"));
assert!(flat.exists());
assert!(!folder(&fx).exists());
assert!(!v2.exists());
let f = fx.fm.file(&flat).unwrap();
assert!(f.tag_paths().contains("Žánr/Fantasy"));
assert!(fx.fm.remove_movie_version(&flat).is_err());
}
#[test]
fn test_grouped_movie_refuses_rename_and_move_but_allows_metadata() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
assert!(fx.fm.rename_movie(&primary, "Jiný").is_err());
assert!(fx.fm.move_to_library(&primary, "Filmy").is_err());
assert_eq!(fx.fm.rename_to_canonical(&primary).unwrap(), None);
let edits = super::super::movies::MovieEdits {
title: "Hobit".into(),
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
date: Some("2026-02-02".into()),
..Default::default()
};
fx.fm.apply_movie_edits(&primary, edits).unwrap();
let bad = super::super::movies::MovieEdits {
title: "Jiný".into(),
year: Some("2013".into()),
..Default::default()
};
assert!(fx.fm.apply_movie_edits(&primary, bad).is_err());
}
#[test]
fn test_rename_movie_version_cases() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&v2), "Žánr/Fantasy")
.unwrap();
let renamed = fx.fm.rename_movie_version(&v2, "Režisérská verze").unwrap();
assert_eq!(
paths::name(&renamed),
format!("{STEM} - Režisérská verze.mkv")
);
assert!(
fx.fm
.file(&renamed)
.unwrap()
.tag_paths()
.contains("Žánr/Fantasy")
);
assert_eq!(
fx.fm
.rename_movie_version(&renamed, "Režisérská verze")
.unwrap(),
renamed
);
assert!(fx.fm.rename_movie_version(&renamed, "").is_err()); // collides with Výchozí
assert!(fx.fm.rename_movie_version(&renamed, "part2").is_err()); // mixes
let flat = fx
.fm
.import_movie(
&fx.source("o.mkv"),
"Other",
ImportOptions {
year: Some("2000".into()),
..Default::default()
},
)
.unwrap()
.unwrap();
assert!(fx.fm.rename_movie_version(&flat, "x").is_err());
}
#[test]
fn test_parts_add_order_move_and_mode_switch() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
let p2 = fx
.fm
.add_movie_part(&movie, &fx.source("h2.mkv"), false)
.unwrap();
let p1 = folder(&fx).join(format!("{STEM}-part1.mkv"));
assert!(p1.exists());
assert_eq!(paths::name(&p2), format!("{STEM}-part2.mkv"));
let p3 = fx
.fm
.add_movie_part(&p1, &fx.source("h3.mkv"), false)
.unwrap();
assert_eq!(paths::name(&p3), format!("{STEM}-part3.mkv"));
assert_eq!(fx.fm.movie_group_mode(&p1), GroupMode::Parts);
assert!(
fx.fm
.add_movie_version(&p1, &fx.source("x.mkv"), "4K", false, None)
.is_err()
);
fs::write(folder(&fx).join(format!("{STEM}-part1.cs.srt")), "one").unwrap();
fx.fm.move_movie_part(&p1, 1).unwrap();
// the old part1 content is now part2, with its subtitle
assert_eq!(
fs::read_to_string(folder(&fx).join(format!("{STEM}-part2.mkv"))).unwrap(),
"h1.mkv"
);
assert_eq!(
fs::read_to_string(folder(&fx).join(format!("{STEM}-part2.cs.srt"))).unwrap(),
"one"
);
fx.fm
.move_movie_part(&folder(&fx).join(format!("{STEM}-part1.mkv")), -1)
.unwrap(); // beyond the end: no-op
let any = folder(&fx).join(format!("{STEM}-part1.mkv"));
assert!(
fx.fm
.set_movie_group_mode(&any, GroupMode::Versions, &["a".into()])
.is_err()
);
assert!(
fx.fm
.set_movie_group_mode(&any, GroupMode::Versions, &["a".into(), "A".into()])
.is_err()
);
fx.fm
.set_movie_group_mode(&any, GroupMode::Versions, &["4K".into(), "3D HSBS".into()])
.unwrap();
let first = folder(&fx).join(format!("{STEM}.mkv"));
assert_eq!(fx.fm.movie_group_mode(&first), GroupMode::Versions);
fx.fm
.set_movie_group_mode(&first, GroupMode::Parts, &[])
.unwrap();
assert_eq!(
fx.fm
.movie_group_mode(&folder(&fx).join(format!("{STEM}-part1.mkv"))),
GroupMode::Parts
);
}
#[test]
fn test_removing_down_to_one_part_demotes_to_flat() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
let p2 = fx
.fm
.add_movie_part(&movie, &fx.source("h2.mkv"), false)
.unwrap();
fx.fm.remove_movie_version(&p2).unwrap();
assert!(fx.pool().join(format!("Filmy/{STEM}.mkv")).exists());
}
#[test]
fn test_remove_movie_deletes_every_version() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
fx.fm.remove_movie(&primary).unwrap();
assert!(!folder(&fx).exists());
assert!(fx.fm.filelist.is_empty());
assert!(fx.fm.index.as_ref().unwrap().records.is_empty());
}
#[test]
fn test_merge_as_version_carries_tags_csfd_and_subtitles() {
let mut fx = Fixture::new();
let keep = hobbit(&mut fx);
let other = fx
.fm
.import_movie(
&fx.source("dup.mkv"),
"Hobit kopie",
ImportOptions::default(),
)
.unwrap()
.unwrap();
fx.fm
.assign_tag(std::slice::from_ref(&other), "Žánr/Fantasy")
.unwrap();
fx.fm
.with_file(&other, |f, _, index| {
f.csfd_url = Some("u".into());
f.save_metadata(index)
})
.unwrap();
fs::write(fx.pool().join("Filmy/Hobit kopie.cs.srt"), "s").unwrap();
let v = fx.fm.merge_as_version(&keep, &other, "4K", None).unwrap();
assert_eq!(v, folder(&fx).join(format!("{STEM} - 4K.mkv")));
assert!(folder(&fx).join(format!("{STEM} - 4K.cs.srt")).exists());
let primary = folder(&fx).join(format!("{STEM}.mkv"));
let f = fx.fm.file(&primary).unwrap();
assert!(f.tag_paths().contains("Žánr/Fantasy"));
assert_eq!(f.csfd_url.as_deref(), Some("u"));
assert_eq!(fx.fm.index.as_ref().unwrap().records.len(), 1);
assert!(
fx.fm
.merge_as_version(&primary, &primary, "x", None)
.is_err()
);
}
#[test]
fn test_mixed_library_version_folder_vs_series() {
let mut fx = Fixture::new();
fx.fm
.set_libraries(vec![Library::new("Mix", LibraryKind::Mixed)])
.unwrap();
let opts = ImportOptions {
year: Some("2013".into()),
tmdb_id: Some("49051".into()),
library: Some("Mix".into()),
..Default::default()
};
let movie = fx
.fm
.import_movie(&fx.source("h1.mkv"), "Hobit", opts)
.unwrap()
.unwrap();
let v2 = fx
.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
assert!(fx.fm.is_movie_group_member(&v2));
let ep = fx
.pool()
.join("Mix/Show (2010) [tmdbid-1]/Season 01/S01E01 - Show.mkv");
fs::create_dir_all(ep.parent().unwrap()).unwrap();
fs::write(&ep, "e").unwrap();
assert!(!fx.fm.is_movie_group_member(&ep));
fx.fm.load_pool_movies().unwrap();
assert_eq!(fx.fm.filelist.len(), 2);
}
#[test]
fn test_normalize_default_version_names() {
let mut fx = Fixture::new();
let movie = hobbit(&mut fx);
fx.fm
.add_movie_version(&movie, &fx.source("h2.mkv"), "4K", false, None)
.unwrap();
let primary = folder(&fx).join(format!("{STEM}.mkv"));
let old = folder(&fx).join(format!("{STEM} - Výchozí.mkv"));
fx.fm.relocate_file(&primary, &old, true).unwrap();
let renamed = fx.fm.normalize_default_version_names().unwrap();
assert_eq!(renamed, vec![(paths::name(&old), paths::name(&primary))]);
assert!(primary.exists());
}
}