871 lines
30 KiB
Rust
871 lines
30 KiB
Rust
//! `FileManager` — the service layer and the only thing that changes the pool.
|
|
//!
|
|
//! Everything the user can do maps onto one of its public methods. Methods that
|
|
//! change files also keep the pool index and the in-memory `filelist` in step.
|
|
//! Files are addressed by their current path.
|
|
|
|
mod minidlna;
|
|
mod movies;
|
|
mod series_ops;
|
|
mod tools;
|
|
mod versions;
|
|
|
|
use std::collections::HashSet;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use super::config::{
|
|
GlobalConfig, TagSchemaEntry, default_tag_schema, global_config_path, load_global_config_from,
|
|
save_global_config_to,
|
|
};
|
|
use super::error::{CoreError, CoreResult};
|
|
use super::file::File;
|
|
use super::naming::is_canonical_pool_stem;
|
|
use super::paths;
|
|
use super::pool_index::PoolIndex;
|
|
use super::series::{Series, looks_like_series_folder, scan_series};
|
|
use super::tag::Tag;
|
|
use super::tag_manager::TagManager;
|
|
|
|
pub use minidlna::{GenerateReport, MIXED_SERIES_SUBFOLDER, RECENT_SUBFOLDER, TIPS_SUBFOLDER};
|
|
pub use movies::{ImportOptions, MovieEdits, OnConflict};
|
|
pub use series_ops::{
|
|
EpisodeCode, ImportEpisodesResult, RenameAllSeriesResult, RenameEpisodesResult,
|
|
};
|
|
pub use tools::{
|
|
ConsistencyReport, DuplicateGroup, IntegrityReport, JellyfinReport, MinidlnaScope,
|
|
RefreshReport, RenameAllReport, ResolveResult, Stats,
|
|
};
|
|
pub use versions::{GroupMode, label_of, version_filename};
|
|
|
|
/// Top-level folders of a fresh pool.
|
|
pub const POOL_MOVIES: &str = "Filmy";
|
|
pub const POOL_SERIES: &str = "Seriály";
|
|
|
|
/// Curator metadata files that must never be treated as content.
|
|
pub const METADATA_SUFFIXES: &[&str] = &[".!tag", ".!ftag", ".!gtag", ".!index"];
|
|
|
|
/// Sidecars Jellyfin writes next to a movie (NFO + artwork / subtitles).
|
|
pub const SIDECAR_SUFFIXES: &[&str] = &[
|
|
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn", ".srt", ".ass", ".ssa",
|
|
".sub", ".idx", ".vtt",
|
|
];
|
|
|
|
/// True for files that are pool content (not Curator metadata / Jellyfin sidecars).
|
|
pub fn is_media_file(path: &Path) -> bool {
|
|
let name = paths::name(path);
|
|
!METADATA_SUFFIXES.iter().any(|s| name.ends_with(s))
|
|
&& !SIDECAR_SUFFIXES.contains(&paths::suffix_lower(path).as_str())
|
|
}
|
|
|
|
/// The kind of a pool library (top-level folder).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
|
pub enum LibraryKind {
|
|
/// Single-file movies, TMDb naming.
|
|
Movie,
|
|
/// Series/season/episode tree (Jellyfin Shows).
|
|
Series,
|
|
/// Movies at the top level, series in subfolders.
|
|
Mixed,
|
|
/// Files without TMDb (home videos), mirrored as-is.
|
|
HomeVideo,
|
|
}
|
|
|
|
impl LibraryKind {
|
|
pub const ALL: [LibraryKind; 4] = [Self::Movie, Self::Series, Self::Mixed, Self::HomeVideo];
|
|
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Movie => "movie",
|
|
Self::Series => "series",
|
|
Self::Mixed => "mixed",
|
|
Self::HomeVideo => "homevideo",
|
|
}
|
|
}
|
|
|
|
/// Unknown kinds read as `movie`, like the Python version.
|
|
pub fn parse(s: &str) -> Self {
|
|
match s {
|
|
"series" => Self::Series,
|
|
"mixed" => Self::Mixed,
|
|
"homevideo" => Self::HomeVideo,
|
|
_ => Self::Movie,
|
|
}
|
|
}
|
|
|
|
/// Czech label for the library organiser.
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Movie => "Filmy (jednotlivé filmy)",
|
|
Self::Series => "Seriály",
|
|
Self::Mixed => "Mix (filmy + seriály)",
|
|
Self::HomeVideo => "Domácí videa (bez TMDb)",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Library {
|
|
pub name: String,
|
|
pub kind: LibraryKind,
|
|
}
|
|
|
|
impl Library {
|
|
pub fn new(name: &str, kind: LibraryKind) -> Self {
|
|
Self {
|
|
name: name.into(),
|
|
kind,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn default_libraries() -> Vec<Library> {
|
|
vec![
|
|
Library::new(POOL_MOVIES, LibraryKind::Movie),
|
|
Library::new(POOL_SERIES, LibraryKind::Series),
|
|
]
|
|
}
|
|
|
|
/// Library settings that moved from the old global config into the index.
|
|
const LEGACY_LIBRARY_SETTINGS: &[&str] = &["tag_schema", "copyasis_folders", "movie_libraries"];
|
|
|
|
fn clean_libraries(specs: impl IntoIterator<Item = Library>) -> Vec<Library> {
|
|
let mut seen = HashSet::new();
|
|
specs
|
|
.into_iter()
|
|
.filter_map(|l| {
|
|
let name = l.name.trim().to_string();
|
|
(!name.is_empty() && seen.insert(name.to_lowercase()))
|
|
.then_some(Library { name, kind: l.kind })
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn str_list(v: Option<&Value>) -> Option<Vec<String>> {
|
|
v.and_then(Value::as_array).map(|a| {
|
|
a.iter()
|
|
.filter_map(|s| s.as_str().map(String::from))
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
pub struct FileManager {
|
|
pub filelist: Vec<File>,
|
|
pub tagmanager: TagManager,
|
|
pub global_config: GlobalConfig,
|
|
/// Unified pool metadata + library settings.
|
|
pub index: Option<PoolIndex>,
|
|
config_path: PathBuf,
|
|
}
|
|
|
|
impl FileManager {
|
|
/// A manager using the per-user global config.
|
|
pub fn new() -> Self {
|
|
Self::with_config_path(global_config_path())
|
|
}
|
|
|
|
/// A manager reading/writing its global config at `config_path` (tests use
|
|
/// a temporary one). Falls back to a legacy `.Curator.!gtag` in the working
|
|
/// directory on first run.
|
|
pub fn with_config_path(config_path: PathBuf) -> Self {
|
|
let global_config = if config_path.exists() {
|
|
load_global_config_from(&config_path)
|
|
} else if config_path == global_config_path() {
|
|
super::config::load_global_config()
|
|
} else {
|
|
GlobalConfig::default()
|
|
};
|
|
Self {
|
|
filelist: Vec::new(),
|
|
tagmanager: TagManager::new(),
|
|
global_config,
|
|
index: None,
|
|
config_path,
|
|
}
|
|
}
|
|
|
|
pub fn save_global_config(&self) -> CoreResult<()> {
|
|
save_global_config_to(&self.global_config, &self.config_path)
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pool and output folders
|
|
// ------------------------------------------------------------------
|
|
|
|
pub fn pool_dir(&self) -> Option<PathBuf> {
|
|
self.global_config
|
|
.pool_dir
|
|
.as_deref()
|
|
.filter(|s| !s.is_empty())
|
|
.map(PathBuf::from)
|
|
}
|
|
|
|
pub fn filmoteka_dir(&self) -> Option<PathBuf> {
|
|
self.global_config
|
|
.filmoteka_dir
|
|
.as_deref()
|
|
.filter(|s| !s.is_empty())
|
|
.map(PathBuf::from)
|
|
}
|
|
|
|
pub(crate) fn require_pool(&self) -> CoreResult<PathBuf> {
|
|
self.pool_dir()
|
|
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))
|
|
}
|
|
|
|
/// Set the pool root and create every library's top-level folder.
|
|
pub fn set_pool_dir(&mut self, pool_dir: &Path) -> CoreResult<()> {
|
|
self.global_config.pool_dir = Some(pool_dir.to_string_lossy().into_owned());
|
|
self.save_global_config()?;
|
|
self.open_index(pool_dir)?;
|
|
for lib in self.libraries() {
|
|
fs::create_dir_all(pool_dir.join(&lib.name))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Set the miniDLNA output folder.
|
|
pub fn set_filmoteka_dir(&mut self, dir: &Path) -> CoreResult<()> {
|
|
self.global_config.filmoteka_dir = Some(dir.to_string_lossy().into_owned());
|
|
self.save_global_config()
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pool index + library-level settings
|
|
// ------------------------------------------------------------------
|
|
|
|
/// (Re)load the pool index and migrate legacy global settings into it.
|
|
fn open_index(&mut self, pool: &Path) -> CoreResult<()> {
|
|
self.index = Some(PoolIndex::open(pool));
|
|
self.migrate_global_settings()
|
|
}
|
|
|
|
pub(crate) fn ensure_index(&mut self) -> Option<&mut PoolIndex> {
|
|
if self.index.is_none()
|
|
&& let Some(pool) = self.pool_dir()
|
|
{
|
|
let _ = self.open_index(&pool);
|
|
}
|
|
self.index.as_mut()
|
|
}
|
|
|
|
fn index_ref(&self) -> Option<&PoolIndex> {
|
|
self.index.as_ref()
|
|
}
|
|
|
|
/// Move library settings from the old global config into the index once.
|
|
fn migrate_global_settings(&mut self) -> CoreResult<()> {
|
|
let Some(index) = self.index.as_mut() else {
|
|
return Ok(());
|
|
};
|
|
let mut moved = false;
|
|
for key in LEGACY_LIBRARY_SETTINGS {
|
|
if let Some(value) = self.global_config.extra.shift_remove(*key) {
|
|
if !index.settings.contains_key(*key) {
|
|
index.set_setting(key, value)?;
|
|
}
|
|
moved = true;
|
|
}
|
|
}
|
|
if moved {
|
|
self.save_global_config()?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn setting(&self, key: &str) -> Option<&Value> {
|
|
self.index_ref()?.get_setting(key)
|
|
}
|
|
|
|
fn set_setting(&mut self, key: &str, value: Value) -> CoreResult<()> {
|
|
let index = self
|
|
.ensure_index()
|
|
.ok_or_else(|| CoreError::invalid("Pool není nastaven — není kam uložit nastavení."))?;
|
|
index.set_setting(key, value)
|
|
}
|
|
|
|
/// All pool libraries (source of truth). Old pools are synthesised from the
|
|
/// legacy `movie_libraries` + `copyasis_folders` settings.
|
|
pub fn libraries(&self) -> Vec<Library> {
|
|
let raw: Vec<Library> = match self.setting("libraries").and_then(Value::as_array) {
|
|
Some(arr) => arr
|
|
.iter()
|
|
.map(|spec| Library {
|
|
name: spec
|
|
.get("name")
|
|
.and_then(Value::as_str)
|
|
.unwrap_or("")
|
|
.to_string(),
|
|
kind: LibraryKind::parse(
|
|
spec.get("kind").and_then(Value::as_str).unwrap_or("movie"),
|
|
),
|
|
})
|
|
.collect(),
|
|
None => {
|
|
let movies = str_list(self.setting("movie_libraries"))
|
|
.unwrap_or_else(|| vec![POOL_MOVIES.into()]);
|
|
let series = str_list(self.setting("copyasis_folders"))
|
|
.unwrap_or_else(|| vec![POOL_SERIES.into()]);
|
|
movies
|
|
.iter()
|
|
.map(|n| Library::new(n, LibraryKind::Movie))
|
|
.chain(series.iter().map(|n| Library::new(n, LibraryKind::Series)))
|
|
.collect()
|
|
}
|
|
};
|
|
let out = clean_libraries(raw);
|
|
if out.is_empty() {
|
|
default_libraries()
|
|
} else {
|
|
out
|
|
}
|
|
}
|
|
|
|
/// Replace the library list and persist it.
|
|
pub fn set_libraries(&mut self, specs: Vec<Library>) -> CoreResult<()> {
|
|
let mut cleaned = clean_libraries(specs);
|
|
if cleaned.is_empty() {
|
|
cleaned = default_libraries();
|
|
}
|
|
let value = Value::Array(
|
|
cleaned
|
|
.iter()
|
|
.map(|l| json!({"name": l.name, "kind": l.kind.as_str()}))
|
|
.collect(),
|
|
);
|
|
self.set_setting("libraries", value)
|
|
}
|
|
|
|
pub fn libraries_of_kind(&self, kinds: &[LibraryKind]) -> Vec<String> {
|
|
self.libraries()
|
|
.into_iter()
|
|
.filter(|l| kinds.contains(&l.kind))
|
|
.map(|l| l.name)
|
|
.collect()
|
|
}
|
|
|
|
pub fn library_kind(&self, name: &str) -> Option<LibraryKind> {
|
|
self.libraries()
|
|
.into_iter()
|
|
.find(|l| l.name == name)
|
|
.map(|l| l.kind)
|
|
}
|
|
|
|
/// Libraries holding single-file movies (never empty).
|
|
pub fn movie_libraries(&self) -> Vec<String> {
|
|
let libs = self.libraries_of_kind(&[LibraryKind::Movie]);
|
|
if libs.is_empty() {
|
|
vec![POOL_MOVIES.into()]
|
|
} else {
|
|
libs
|
|
}
|
|
}
|
|
|
|
/// Libraries a movie can live in — kind movie or mixed (never empty).
|
|
pub fn libraries_accepting_movies(&self) -> Vec<String> {
|
|
let libs = self.libraries_of_kind(&[LibraryKind::Movie, LibraryKind::Mixed]);
|
|
if libs.is_empty() {
|
|
vec![POOL_MOVIES.into()]
|
|
} else {
|
|
libs
|
|
}
|
|
}
|
|
|
|
/// Libraries that can hold series — kind series or mixed.
|
|
pub fn series_libraries(&self) -> Vec<String> {
|
|
self.libraries_of_kind(&[LibraryKind::Series, LibraryKind::Mixed])
|
|
}
|
|
|
|
/// The series (copy-as-is) libraries.
|
|
pub fn copyasis_folders(&self) -> Vec<String> {
|
|
self.libraries_of_kind(&[LibraryKind::Series])
|
|
}
|
|
|
|
/// Libraries hardcopied 1:1 into miniDLNA — series and homevideo.
|
|
pub fn mirror_as_is_libraries(&self) -> Vec<String> {
|
|
self.libraries_of_kind(&[LibraryKind::Series, LibraryKind::HomeVideo])
|
|
}
|
|
|
|
/// Libraries included in miniDLNA generation, `None` = all. Unknown names
|
|
/// are dropped on read.
|
|
pub fn minidlna_libraries(&self) -> Option<Vec<String>> {
|
|
let raw = str_list(self.setting("minidlna_libraries"))?;
|
|
let known: HashSet<String> = self.libraries().into_iter().map(|l| l.name).collect();
|
|
Some(raw.into_iter().filter(|n| known.contains(n)).collect())
|
|
}
|
|
|
|
/// Restrict miniDLNA generation to `names`; `None` resets to "all".
|
|
pub fn set_minidlna_libraries(&mut self, names: Option<Vec<String>>) -> CoreResult<()> {
|
|
let value = match names {
|
|
None => Value::Null,
|
|
Some(list) => {
|
|
let mut seen = HashSet::new();
|
|
Value::Array(
|
|
list.into_iter()
|
|
.filter(|n| seen.insert(n.clone()))
|
|
.map(Value::from)
|
|
.collect(),
|
|
)
|
|
}
|
|
};
|
|
if value.is_null() {
|
|
let index = self.ensure_index().ok_or_else(|| {
|
|
CoreError::invalid("Pool není nastaven — není kam uložit nastavení.")
|
|
})?;
|
|
index
|
|
.settings
|
|
.insert("minidlna_libraries".into(), Value::Null);
|
|
return index.save();
|
|
}
|
|
self.set_setting("minidlna_libraries", value)
|
|
}
|
|
|
|
/// Top-level pool folders that aren't a configured library: `(name, is_empty)`.
|
|
pub fn orphan_pool_folders(&self) -> Vec<(String, bool)> {
|
|
let Some(pool) = self.pool_dir().filter(|p| p.is_dir()) else {
|
|
return Vec::new();
|
|
};
|
|
let known: HashSet<String> = self.libraries().into_iter().map(|l| l.name).collect();
|
|
paths::subdirs(&pool)
|
|
.into_iter()
|
|
.filter(|d| !known.contains(&paths::name(d)))
|
|
.map(|d| (paths::name(&d), paths::is_empty_dir(&d)))
|
|
.collect()
|
|
}
|
|
|
|
/// Delete an **empty** orphan folder in the pool root.
|
|
pub fn remove_empty_pool_folder(&self, name: &str) -> bool {
|
|
let Some(pool) = self.pool_dir() else {
|
|
return false;
|
|
};
|
|
let known = self.libraries().iter().any(|l| l.name == name);
|
|
let target = pool.join(name);
|
|
!known && paths::is_empty_dir(&target) && fs::remove_dir(&target).is_ok()
|
|
}
|
|
|
|
/// Which library (top-level pool folder) a pooled path lives in.
|
|
pub fn library_of(&self, path: &Path) -> Option<String> {
|
|
let pool = self.pool_dir()?;
|
|
let rel = path.strip_prefix(&pool).ok()?;
|
|
rel.components()
|
|
.next()
|
|
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
|
}
|
|
|
|
pub fn tag_schema(&self) -> Vec<TagSchemaEntry> {
|
|
self.setting("tag_schema")
|
|
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
|
.unwrap_or_else(default_tag_schema)
|
|
}
|
|
|
|
pub fn set_tag_schema(&mut self, schema: &[TagSchemaEntry]) -> CoreResult<()> {
|
|
self.set_setting("tag_schema", serde_json::to_value(schema)?)
|
|
}
|
|
|
|
/// The miniDLNA tree spec derived from the tag schema.
|
|
pub fn tree_spec(&self) -> super::hardlink_manager::TreeSpec {
|
|
let schema = self.tag_schema();
|
|
let mut spec = super::hardlink_manager::TreeSpec {
|
|
roots: Some(
|
|
schema
|
|
.iter()
|
|
.filter_map(|e| Some((e.category.clone(), e.filmoteka_root.clone()?)))
|
|
.collect(),
|
|
),
|
|
..Default::default()
|
|
};
|
|
for e in schema.iter().filter(|e| e.filmoteka_root.is_some()) {
|
|
if let Some(t) = e.transform.clone().filter(|t| !t.is_empty()) {
|
|
spec.transforms.insert(e.category.clone(), t);
|
|
}
|
|
if let Some(t) = e.filename_template.clone().filter(|t| !t.is_empty()) {
|
|
spec.templates.insert(e.category.clone(), t);
|
|
}
|
|
}
|
|
spec
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Loading
|
|
// ------------------------------------------------------------------
|
|
|
|
/// Reload the movie list from every movie/mixed library via the index.
|
|
pub fn load_pool_movies(&mut self) -> CoreResult<()> {
|
|
self.filelist.clear();
|
|
self.tagmanager.clear();
|
|
let Some(pool) = self.pool_dir() else {
|
|
return Ok(());
|
|
};
|
|
self.open_index(&pool)?;
|
|
let mut candidates = Vec::new();
|
|
for lib in self.libraries() {
|
|
let lib_dir = pool.join(&lib.name);
|
|
if !lib_dir.is_dir() {
|
|
continue;
|
|
}
|
|
match lib.kind {
|
|
LibraryKind::Movie => candidates.extend(paths::walk_files(&lib_dir)),
|
|
LibraryKind::Mixed => {
|
|
// A mixed library keeps movies at the top level and series in
|
|
// subfolders; a movie's version folder has the same shape as a
|
|
// series folder but never contains a Season folder.
|
|
candidates.extend(paths::entries(&lib_dir).into_iter().filter(|p| p.is_file()));
|
|
for sub in paths::subdirs(&lib_dir) {
|
|
if is_canonical_pool_stem(&paths::name(&sub))
|
|
&& !looks_like_series_folder(&sub)
|
|
{
|
|
candidates
|
|
.extend(paths::entries(&sub).into_iter().filter(|p| p.is_file()));
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let index = self
|
|
.index
|
|
.as_mut()
|
|
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
|
|
for path in candidates.into_iter().filter(|p| is_media_file(p)) {
|
|
self.filelist
|
|
.push(File::load(&path, &mut self.tagmanager, Some(index))?);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Scan every series library.
|
|
pub fn load_series(&self) -> Vec<Series> {
|
|
let Some(pool) = self.pool_dir() else {
|
|
return Vec::new();
|
|
};
|
|
self.libraries()
|
|
.into_iter()
|
|
.filter(|l| matches!(l.kind, LibraryKind::Series | LibraryKind::Mixed))
|
|
.flat_map(|l| scan_series(&pool.join(&l.name), &l.name, l.kind == LibraryKind::Mixed))
|
|
.collect()
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// File lookup helpers
|
|
// ------------------------------------------------------------------
|
|
|
|
pub fn position(&self, path: &Path) -> Option<usize> {
|
|
self.filelist.iter().position(|f| f.file_path == path)
|
|
}
|
|
|
|
pub fn file(&self, path: &Path) -> Option<&File> {
|
|
self.filelist.iter().find(|f| f.file_path == path)
|
|
}
|
|
|
|
/// Run `op` on the loaded file at `path`, or on a freshly loaded transient
|
|
/// one when it isn't in `filelist`.
|
|
pub(crate) fn with_file<R>(
|
|
&mut self,
|
|
path: &Path,
|
|
op: impl FnOnce(&mut File, &mut TagManager, &mut PoolIndex) -> CoreResult<R>,
|
|
) -> CoreResult<R> {
|
|
if self.ensure_index().is_none() {
|
|
return Err(CoreError::invalid("Pool není nastaven."));
|
|
}
|
|
let Self {
|
|
filelist,
|
|
tagmanager,
|
|
index,
|
|
..
|
|
} = self;
|
|
let index = index
|
|
.as_mut()
|
|
.ok_or_else(|| CoreError::invalid("Pool není nastaven."))?;
|
|
match filelist.iter_mut().find(|f| f.file_path == path) {
|
|
Some(f) => op(f, tagmanager, index),
|
|
None => {
|
|
let mut f = File::load(path, tagmanager, Some(index))?;
|
|
op(&mut f, tagmanager, index)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Set (or clear with `None`) the manual date of files.
|
|
pub fn set_date(&mut self, files: &[PathBuf], date: Option<&str>) -> CoreResult<()> {
|
|
let date = date
|
|
.map(str::trim)
|
|
.filter(|d| !d.is_empty())
|
|
.map(String::from);
|
|
for path in files {
|
|
let d = date.clone();
|
|
self.with_file(path, |f, _, index| {
|
|
f.date = d;
|
|
f.save_metadata(index)
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Persist a loaded file's metadata.
|
|
pub fn save_file(&mut self, path: &Path) -> CoreResult<()> {
|
|
self.with_file(path, |f, _, index| f.save_metadata(index))
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Tags
|
|
// ------------------------------------------------------------------
|
|
|
|
/// Assign a `Category/Name` tag (no category → "default") to files.
|
|
pub fn assign_tag(&mut self, files: &[PathBuf], tag: &str) -> CoreResult<()> {
|
|
let tag = Tag::from_full_path(tag).unwrap_or_else(|| Tag::new("default", tag));
|
|
for path in files {
|
|
self.with_file(path, |f, tm, index| {
|
|
let t = tm.add_tag(&tag.category, &tag.name);
|
|
if !f.tags.contains(&t) {
|
|
f.tags.push(t);
|
|
f.save_metadata(index)?;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove a tag from files.
|
|
pub fn remove_tag(&mut self, files: &[PathBuf], tag: &str) -> CoreResult<()> {
|
|
let tag = Tag::from_full_path(tag).unwrap_or_else(|| Tag::new("default", tag));
|
|
for path in files {
|
|
self.with_file(path, |f, _, index| {
|
|
if let Some(i) = f.tags.iter().position(|t| *t == tag) {
|
|
f.tags.remove(i);
|
|
f.save_metadata(index)?;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Files carrying **all** of `tags` (full paths); empty = all files.
|
|
pub fn filter_files_by_tags(&self, tags: &HashSet<String>) -> Vec<&File> {
|
|
self.filelist
|
|
.iter()
|
|
.filter(|f| {
|
|
tags.iter()
|
|
.all(|t| f.tags.iter().any(|ft| &ft.full_path() == t))
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl Default for FileManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod test_support {
|
|
use super::*;
|
|
|
|
/// A manager with a temporary config and pool (`<tmp>/pool`).
|
|
pub struct Fixture {
|
|
pub dir: tempfile::TempDir,
|
|
pub fm: FileManager,
|
|
}
|
|
|
|
impl Fixture {
|
|
pub fn new() -> Self {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut fm = FileManager::with_config_path(dir.path().join("cfg.json"));
|
|
fm.set_pool_dir(&dir.path().join("pool")).unwrap();
|
|
Self { dir, fm }
|
|
}
|
|
|
|
pub fn pool(&self) -> PathBuf {
|
|
self.dir.path().join("pool")
|
|
}
|
|
|
|
/// A source file outside the pool.
|
|
pub fn source(&self, name: &str) -> PathBuf {
|
|
let p = self.dir.path().join("src").join(name);
|
|
fs::create_dir_all(p.parent().unwrap()).unwrap();
|
|
fs::write(&p, name.as_bytes()).unwrap();
|
|
p
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::test_support::Fixture;
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_set_pool_creates_top_level_folders() {
|
|
let fx = Fixture::new();
|
|
assert!(fx.pool().join("Filmy").is_dir());
|
|
assert!(fx.pool().join("Seriály").is_dir());
|
|
}
|
|
|
|
#[test]
|
|
fn test_libraries_default_and_set_all_kinds() {
|
|
let mut fx = Fixture::new();
|
|
assert_eq!(fx.fm.libraries(), default_libraries());
|
|
fx.fm
|
|
.set_libraries(vec![
|
|
Library::new("Filmy", LibraryKind::Movie),
|
|
Library::new("Kids", LibraryKind::Mixed),
|
|
Library::new("Domácí", LibraryKind::HomeVideo),
|
|
Library::new("filmy", LibraryKind::Series),
|
|
Library::new(" ", LibraryKind::Movie),
|
|
])
|
|
.unwrap();
|
|
let names: Vec<String> = fx.fm.libraries().into_iter().map(|l| l.name).collect();
|
|
assert_eq!(names, vec!["Filmy", "Kids", "Domácí"]);
|
|
assert_eq!(fx.fm.libraries_accepting_movies(), vec!["Filmy", "Kids"]);
|
|
assert_eq!(fx.fm.mirror_as_is_libraries(), vec!["Domácí"]);
|
|
assert!(fx.fm.copyasis_folders().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_library_settings_stored_in_index_not_global() {
|
|
let mut fx = Fixture::new();
|
|
fx.fm
|
|
.set_libraries(vec![Library::new("A", LibraryKind::Movie)])
|
|
.unwrap();
|
|
let raw = fs::read_to_string(fx.pool().join(".Curator.!index")).unwrap();
|
|
assert!(raw.contains("\"libraries\""));
|
|
let cfg = fs::read_to_string(fx.dir.path().join("cfg.json")).unwrap();
|
|
assert!(!cfg.contains("libraries"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_migrates_legacy_global_settings_into_index() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let cfg = dir.path().join("cfg.json");
|
|
let pool = dir.path().join("pool");
|
|
fs::write(
|
|
&cfg,
|
|
json!({"pool_dir": pool, "copyasis_folders": ["Seriály", "Dokumenty"]}).to_string(),
|
|
)
|
|
.unwrap();
|
|
let mut fm = FileManager::with_config_path(cfg.clone());
|
|
fm.load_pool_movies().unwrap();
|
|
assert_eq!(fm.copyasis_folders(), vec!["Seriály", "Dokumenty"]);
|
|
assert!(
|
|
!fs::read_to_string(&cfg)
|
|
.unwrap()
|
|
.contains("copyasis_folders")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_minidlna_libraries_default_set_and_unknown() {
|
|
let mut fx = Fixture::new();
|
|
assert_eq!(fx.fm.minidlna_libraries(), None);
|
|
fx.fm
|
|
.set_minidlna_libraries(Some(vec!["Filmy".into(), "Gone".into(), "Filmy".into()]))
|
|
.unwrap();
|
|
assert_eq!(fx.fm.minidlna_libraries(), Some(vec!["Filmy".to_string()]));
|
|
fx.fm.set_minidlna_libraries(None).unwrap();
|
|
assert_eq!(fx.fm.minidlna_libraries(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_orphan_folder_cleanup() {
|
|
let fx = Fixture::new();
|
|
fs::create_dir_all(fx.pool().join("Empty")).unwrap();
|
|
fs::create_dir_all(fx.pool().join("Full")).unwrap();
|
|
fs::write(fx.pool().join("Full/x.mkv"), "").unwrap();
|
|
assert_eq!(
|
|
fx.fm.orphan_pool_folders(),
|
|
vec![("Empty".to_string(), true), ("Full".to_string(), false)]
|
|
);
|
|
assert!(fx.fm.remove_empty_pool_folder("Empty"));
|
|
assert!(!fx.fm.remove_empty_pool_folder("Full"));
|
|
assert!(!fx.fm.remove_empty_pool_folder("Filmy"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tree_spec_from_schema() {
|
|
let fx = Fixture::new();
|
|
let spec = fx.fm.tree_spec();
|
|
let roots = spec.roots.unwrap();
|
|
assert_eq!(roots["Žánr"], "");
|
|
assert_eq!(roots["Rok"], "- Dle roku");
|
|
assert_eq!(spec.transforms["Hodnocení"], "decade_band");
|
|
}
|
|
|
|
#[test]
|
|
fn test_tag_assign_remove_and_filter() {
|
|
let mut fx = Fixture::new();
|
|
let a = fx
|
|
.fm
|
|
.import_movie(&fx.source("a.mkv"), "A", Default::default())
|
|
.unwrap()
|
|
.unwrap();
|
|
let b = fx
|
|
.fm
|
|
.import_movie(&fx.source("b.mkv"), "B", Default::default())
|
|
.unwrap()
|
|
.unwrap();
|
|
fx.fm
|
|
.assign_tag(&[a.clone(), b.clone()], "Žánr/Drama")
|
|
.unwrap();
|
|
fx.fm
|
|
.assign_tag(std::slice::from_ref(&a), "Rok/1999")
|
|
.unwrap();
|
|
fx.fm
|
|
.assign_tag(std::slice::from_ref(&a), "Rok/1999")
|
|
.unwrap();
|
|
assert_eq!(fx.fm.file(&a).unwrap().tags.len(), 2);
|
|
let want: HashSet<String> = ["Žánr/Drama".to_string(), "Rok/1999".to_string()]
|
|
.into_iter()
|
|
.collect();
|
|
assert_eq!(fx.fm.filter_files_by_tags(&want).len(), 1);
|
|
assert_eq!(fx.fm.filter_files_by_tags(&HashSet::new()).len(), 2);
|
|
fx.fm
|
|
.remove_tag(std::slice::from_ref(&a), "Žánr/Drama")
|
|
.unwrap();
|
|
assert_eq!(fx.fm.file(&a).unwrap().tags.len(), 1);
|
|
fx.fm
|
|
.assign_tag(std::slice::from_ref(&b), "bezkategorie")
|
|
.unwrap();
|
|
assert!(
|
|
fx.fm
|
|
.file(&b)
|
|
.unwrap()
|
|
.tags
|
|
.contains(&Tag::new("default", "bezkategorie"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_scans_movie_and_mixed_libraries_and_skips_sidecars() {
|
|
let mut fx = Fixture::new();
|
|
fx.fm
|
|
.set_libraries(vec![
|
|
Library::new("Filmy", LibraryKind::Movie),
|
|
Library::new("Mix", LibraryKind::Mixed),
|
|
Library::new("Seriály", LibraryKind::Series),
|
|
])
|
|
.unwrap();
|
|
let pool = fx.pool();
|
|
for rel in [
|
|
"Filmy/A (2000).mkv",
|
|
"Filmy/A (2000).nfo",
|
|
"Filmy/A (2000).cs.srt",
|
|
"Filmy/sub/B (2001).mkv",
|
|
"Mix/C (2002).mkv",
|
|
"Mix/Show (2010) [tmdbid-1]/Season 01/S01E01 - Show.mkv",
|
|
"Seriály/X/Season 01/S01E01 - X.mkv",
|
|
] {
|
|
fs::create_dir_all(pool.join(rel).parent().unwrap()).unwrap();
|
|
fs::write(pool.join(rel), "x").unwrap();
|
|
}
|
|
fx.fm.load_pool_movies().unwrap();
|
|
let names: Vec<String> = fx.fm.filelist.iter().map(|f| f.filename.clone()).collect();
|
|
assert_eq!(names, vec!["A (2000).mkv", "B (2001).mkv", "C (2002).mkv"]);
|
|
let series: Vec<String> = fx.fm.load_series().into_iter().map(|s| s.title).collect();
|
|
assert_eq!(series, vec!["Show", "X"]);
|
|
}
|
|
}
|