Rewrite Curator in Rust with an egui GUI
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user