999 lines
37 KiB
Rust
999 lines
37 KiB
Rust
//! 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());
|
|
}
|
|
}
|