2506 lines
103 KiB
Python
2506 lines
103 KiB
Python
import re
|
|
import shutil
|
|
from collections import Counter
|
|
from collections.abc import Callable, Iterable, Mapping
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from src.core.config import DEFAULT_TAG_SCHEMA, load_global_config, save_global_config
|
|
|
|
from . import subtitles
|
|
from .file import File
|
|
from .integrity import IntegrityResult, check_video_integrity, ffmpeg_available
|
|
from .naming import (
|
|
canonical_pool_stem,
|
|
is_canonical_pool_stem,
|
|
is_valid_year,
|
|
parse_3d_format,
|
|
parse_pool_stem,
|
|
parse_tmdb_id,
|
|
)
|
|
from .pool_index import PoolIndex
|
|
from .tag_manager import TagManager
|
|
from .utils import list_files
|
|
|
|
# Top-level folders inside the managed pool
|
|
POOL_MOVIES = "Filmy"
|
|
POOL_SERIES = "Seriály"
|
|
|
|
# A pool library (top-level folder) has one of these kinds:
|
|
# movie — single-file movies, TMDb naming (Title (rok) [tmdbid].ext)
|
|
# series — series/season/episode tree, mirrored as-is (Jellyfin Shows)
|
|
# mixed — both movies and series in one folder (Jellyfin "Mixed"; wiring TBD)
|
|
# homevideo — files without TMDb (home videos; Jellyfin "Home videos"; wiring TBD)
|
|
LIBRARY_KINDS = ("movie", "series", "mixed", "homevideo")
|
|
_DEFAULT_LIBRARIES = [
|
|
{"name": POOL_MOVIES, "kind": "movie"},
|
|
{"name": POOL_SERIES, "kind": "series"},
|
|
]
|
|
|
|
# Library-level settings that live in the pool index (not the global config).
|
|
# ``libraries`` is the source of truth; the legacy ``movie_libraries`` /
|
|
# ``copyasis_folders`` are kept only to synthesise ``libraries`` for old pools.
|
|
_LIBRARY_SETTINGS_DEFAULTS = {
|
|
"tag_schema": DEFAULT_TAG_SCHEMA,
|
|
"copyasis_folders": [POOL_SERIES],
|
|
"movie_libraries": [POOL_MOVIES],
|
|
}
|
|
|
|
# Curator metadata files that must never be treated as content. The legacy
|
|
# per-file (.!tag) and per-folder (.!ftag) sidecars are no longer written, but
|
|
# stray ones are still skipped so they never leak into the Filmotéka.
|
|
METADATA_SUFFIXES = (".!tag", ".!ftag", ".!gtag", ".!index")
|
|
|
|
# Sidecars Jellyfin writes next to a movie (NFO + artwork/subtitles). These are
|
|
# not movies and must be skipped when scanning a library.
|
|
SIDECAR_SUFFIXES = (
|
|
".nfo",
|
|
".jpg",
|
|
".jpeg",
|
|
".png",
|
|
".webp",
|
|
".bmp",
|
|
".gif",
|
|
".tbn",
|
|
".srt",
|
|
".ass",
|
|
".ssa",
|
|
".sub",
|
|
".idx",
|
|
".vtt",
|
|
)
|
|
|
|
|
|
def _version_filename(stem: str, label: str, suffix: str) -> str:
|
|
"""Filename of one version of a movie folder.
|
|
|
|
The default version ("Výchozí", or no label at all) carries no label —
|
|
it's just ``<stem><suffix>``, the same name the movie had flat. A part
|
|
label (``part2``, ``CD2``, … see :func:`movie.normalize_part_label`) is
|
|
``<stem>-part<N><suffix>``. Any other version is ``<stem> - <label><suffix>``.
|
|
"""
|
|
from .movie import DEFAULT_VERSION_LABEL, normalize_part_label
|
|
|
|
label = (label or "").strip()
|
|
if not label or label == DEFAULT_VERSION_LABEL:
|
|
return f"{stem}{suffix}"
|
|
part = normalize_part_label(label)
|
|
if part is not None:
|
|
# A part (stacking), not a version: Jellyfin's documented "-partN" form,
|
|
# so the pieces play back to back as one movie.
|
|
return f"{stem}-part{part}{suffix}"
|
|
return f"{stem} - {label}{suffix}"
|
|
|
|
|
|
_PARTS_AND_VERSIONS_MIX = (
|
|
"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."
|
|
)
|
|
|
|
|
|
def _mixes_parts_and_versions(labels: Iterable[str]) -> bool:
|
|
"""True when ``labels`` contain both part labels (``partN``) and version
|
|
labels — a folder must hold one kind only."""
|
|
from .movie import normalize_part_label
|
|
|
|
kinds = {normalize_part_label(label) is not None for label in labels}
|
|
return len(kinds) > 1
|
|
|
|
|
|
class FileManager:
|
|
def __init__(self, tagmanager: TagManager):
|
|
self.filelist: list[File] = []
|
|
self.tagmanager = tagmanager
|
|
self.on_files_changed = None # callback do GUI
|
|
self.global_config = load_global_config()
|
|
self.index: PoolIndex | None = None # unified pool metadata + settings
|
|
|
|
# ------------------------------------------------------------------
|
|
# Pool (single source of truth) and Filmotéka (generated output)
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def pool_dir(self) -> Path | None:
|
|
value = self.global_config.get("pool_dir")
|
|
return Path(value) if value else None
|
|
|
|
@property
|
|
def movies_dir(self) -> Path | None:
|
|
pool = self.pool_dir
|
|
return pool / POOL_MOVIES if pool else None
|
|
|
|
@property
|
|
def series_dir(self) -> Path | None:
|
|
pool = self.pool_dir
|
|
return pool / POOL_SERIES if pool else None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Pool index + library-level settings (stored in the index, per pool)
|
|
# ------------------------------------------------------------------
|
|
|
|
def _open_index(self, pool: Path) -> PoolIndex:
|
|
"""(Re)load the pool index and migrate any legacy global settings."""
|
|
self.index = PoolIndex(pool)
|
|
self._migrate_global_settings()
|
|
return self.index
|
|
|
|
def _ensure_index(self) -> PoolIndex | None:
|
|
"""Return the pool index, opening it lazily when a pool is configured."""
|
|
if self.index is None and self.pool_dir:
|
|
self._open_index(self.pool_dir)
|
|
return self.index
|
|
|
|
def _migrate_global_settings(self) -> None:
|
|
"""Move library settings from the old global config into the index once.
|
|
|
|
Earlier versions stored ``tag_schema`` / ``copyasis_folders`` in the
|
|
global ``.!gtag``. They now belong to the pool; seed the index from the
|
|
old values (if present and not already migrated) and drop them from the
|
|
global config so it carries app data only.
|
|
"""
|
|
if self.index is None:
|
|
return
|
|
moved = False
|
|
for key in _LIBRARY_SETTINGS_DEFAULTS:
|
|
if key in self.global_config:
|
|
if key not in self.index.settings:
|
|
self.index.set_setting(key, self.global_config[key])
|
|
del self.global_config[key]
|
|
moved = True
|
|
if moved:
|
|
save_global_config(self.global_config)
|
|
|
|
def _get_setting(self, key: str):
|
|
"""Read a library setting from the index (falls back to the default)."""
|
|
default = _LIBRARY_SETTINGS_DEFAULTS[key]
|
|
idx = self._ensure_index()
|
|
if idx is None:
|
|
return default
|
|
return idx.get_setting(key, default)
|
|
|
|
def _set_setting(self, key: str, value) -> None:
|
|
"""Persist a library setting into the index (needs a configured pool)."""
|
|
idx = self._ensure_index()
|
|
if idx is None:
|
|
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
|
|
idx.set_setting(key, value)
|
|
|
|
@property
|
|
def libraries(self) -> list[dict]:
|
|
"""All pool libraries as ``[{"name", "kind"}, …]`` (source of truth).
|
|
|
|
For pools predating the unified model this is synthesised from the legacy
|
|
``movie_libraries`` (→ kind ``movie``) + ``copyasis_folders`` (→ ``series``).
|
|
"""
|
|
idx = self._ensure_index()
|
|
raw = idx.get_setting("libraries", None) if idx is not None else None
|
|
if raw is None:
|
|
raw = [
|
|
{"name": n, "kind": "movie"}
|
|
for n in self._get_setting("movie_libraries")
|
|
] + [
|
|
{"name": n, "kind": "series"}
|
|
for n in self._get_setting("copyasis_folders")
|
|
]
|
|
out: list[dict] = []
|
|
seen: set[str] = set()
|
|
for spec in raw:
|
|
name = (spec.get("name") or "").strip()
|
|
kind = spec.get("kind") if spec.get("kind") in LIBRARY_KINDS else "movie"
|
|
if name and name.lower() not in seen:
|
|
out.append({"name": name, "kind": kind})
|
|
seen.add(name.lower())
|
|
return out or list(_DEFAULT_LIBRARIES)
|
|
|
|
def set_libraries(self, specs: list[dict]) -> None:
|
|
"""Replace the library list (list of ``{"name", "kind"}``) and persist it."""
|
|
cleaned: list[dict] = []
|
|
seen: set[str] = set()
|
|
for spec in specs:
|
|
name = (spec.get("name") or "").strip()
|
|
kind = spec.get("kind") if spec.get("kind") in LIBRARY_KINDS else "movie"
|
|
if name and name.lower() not in seen:
|
|
cleaned.append({"name": name, "kind": kind})
|
|
seen.add(name.lower())
|
|
self._set_setting("libraries", cleaned or list(_DEFAULT_LIBRARIES))
|
|
|
|
def libraries_of_kind(self, *kinds: str) -> list[str]:
|
|
"""Names of libraries matching any of ``kinds``."""
|
|
return [lib["name"] for lib in self.libraries if lib["kind"] in kinds]
|
|
|
|
@property
|
|
def minidlna_libraries(self) -> list[str] | None:
|
|
"""Libraries included in miniDLNA generation, or ``None`` for "all" (default).
|
|
|
|
Persisted in the pool index (it describes this pool, not app state) via
|
|
*miniDLNA → "Knihovny pro generování…"*. Names no longer in
|
|
:attr:`libraries` are dropped when read, so a renamed/removed library
|
|
can't silently wedge the selection.
|
|
"""
|
|
idx = self._ensure_index()
|
|
raw = idx.get_setting("minidlna_libraries", None) if idx is not None else None
|
|
if raw is None:
|
|
return None
|
|
known = {lib["name"] for lib in self.libraries}
|
|
return [n for n in raw if n in known]
|
|
|
|
def set_minidlna_libraries(self, names: list[str] | None) -> None:
|
|
"""Restrict miniDLNA generation to ``names``; ``None`` resets to "all"."""
|
|
self._set_setting_raw(
|
|
"minidlna_libraries", None if names is None else list(dict.fromkeys(names))
|
|
)
|
|
|
|
def _set_setting_raw(self, key: str, value) -> None:
|
|
"""Persist an arbitrary pool-index setting (no legacy-default fallback)."""
|
|
idx = self._ensure_index()
|
|
if idx is None:
|
|
raise RuntimeError("Pool není nastaven — není kam uložit nastavení.")
|
|
idx.set_setting(key, value)
|
|
|
|
def minidlna_scope(self) -> tuple[list[File], list[str], list]:
|
|
"""``(movies, mirrored library names, mixed-library series)`` for miniDLNA.
|
|
|
|
Narrowed to :attr:`minidlna_libraries` when set — movies by
|
|
:meth:`library_of`, mirrored libraries by name, mixed-library series by
|
|
their library — otherwise the whole pool (today's behaviour). Excluding
|
|
a movie library here means its genre-tree links (and its share of "Tipy
|
|
dne" / "Nově přidané") are swept as obsolete on the next generation, not
|
|
merely skipped — the caller should say so before generating.
|
|
|
|
The **mirrored libraries** are ``series`` (copy-as-is) *and*
|
|
``homevideo`` kinds — neither has genre tags to build a tree from
|
|
(homevideo carries no TMDb id either), so both are hardcopied whole
|
|
into ``output/<library name>`` instead of going through the tag tree.
|
|
|
|
A ``mixed`` library isn't itself mirrored (its top-level files are
|
|
movies, tagged into the genre tree like any other), but any **series
|
|
sitting in its subfolders** have no genre tags to speak of — those are
|
|
returned separately so the caller can hardcopy them too (e.g. into
|
|
``output/Seriály/<jejich vlastní název>``).
|
|
"""
|
|
selected = self.minidlna_libraries
|
|
mixed_libs = set(self.libraries_of_kind("mixed"))
|
|
if selected is not None:
|
|
mixed_libs &= set(selected)
|
|
mixed_series = (
|
|
[s for s in self.load_series() if s.library in mixed_libs]
|
|
if mixed_libs
|
|
else []
|
|
)
|
|
|
|
mirror_libs = self.mirror_as_is_libraries
|
|
if selected is None:
|
|
return list(self.filelist), mirror_libs, mixed_series
|
|
chosen = set(selected)
|
|
movies = [f for f in self.filelist if self.library_of(f) in chosen]
|
|
mirrors = [name for name in mirror_libs if name in chosen]
|
|
return movies, mirrors, mixed_series
|
|
|
|
def minidlna_prune_targets(
|
|
self,
|
|
mirror_libs: list[str],
|
|
mixed_series: list,
|
|
mixed_series_bucket: str,
|
|
) -> tuple[list[str], set[str]]:
|
|
"""What miniDLNA output to remove for mirrors that fell out of scope.
|
|
|
|
``mirror_libs`` / ``mixed_series`` come from :meth:`minidlna_scope` (the
|
|
*current* run's selection); ``mixed_series_bucket`` is the shared output
|
|
folder mixed-library series hardcopy into (e.g. ``"Seriály"``) — the same
|
|
name a real ``series``/``homevideo`` library may also use, so the two
|
|
sources are reconciled together rather than one blindly wiping the other.
|
|
|
|
Returns ``(stale_libraries, valid_bucket_children)``:
|
|
|
|
- ``stale_libraries`` — mirrored-library output folders (``output/<name>``)
|
|
to delete **entirely**: every :attr:`mirror_as_is_libraries` name no
|
|
longer in ``mirror_libs``, except one that shares the bucket's name
|
|
(reconciled via the second element instead).
|
|
- ``valid_bucket_children`` — basenames that must remain directly under
|
|
``output/<mixed_series_bucket>`` this run: the in-scope mixed-library
|
|
series' own folder names, plus — when a real library named
|
|
``mixed_series_bucket`` is itself in scope — that library's current
|
|
subfolders. Anything else found there is stale.
|
|
"""
|
|
in_scope = set(mirror_libs)
|
|
stale = [
|
|
name
|
|
for name in self.mirror_as_is_libraries
|
|
if name not in in_scope and name != mixed_series_bucket
|
|
]
|
|
|
|
valid = {ser.folder.name for ser in mixed_series}
|
|
if mixed_series_bucket in in_scope:
|
|
pool = self.pool_dir
|
|
src = pool / mixed_series_bucket if pool else None
|
|
if src and src.is_dir():
|
|
valid |= {p.name for p in src.iterdir() if p.is_dir()}
|
|
return stale, valid
|
|
|
|
@property
|
|
def mirror_as_is_libraries(self) -> list[str]:
|
|
"""Libraries hardcopied 1:1 into miniDLNA — ``series`` and ``homevideo``.
|
|
|
|
Neither has genre tags to build a tree from (homevideo has no TMDb id
|
|
either), so both mirror whole into ``output/<library name>`` instead of
|
|
going through the tag tree. A superset of :attr:`copyasis_folders`
|
|
(which the library organizer UI still calls "copy-as-is" and keeps
|
|
scoped to kind ``series`` only).
|
|
"""
|
|
return self.libraries_of_kind("series", "homevideo")
|
|
|
|
@property
|
|
def copyasis_folders(self) -> list[str]:
|
|
"""Names of pool subfolders mirrored 1:1 (copy-as-is) — the series libraries."""
|
|
return self.libraries_of_kind("series")
|
|
|
|
def set_copyasis_folders(self, names: list[str]) -> None:
|
|
"""Set the series (copy-as-is) libraries, keeping the other kinds."""
|
|
cleaned = [n.strip() for n in names if n.strip()]
|
|
others = [lib for lib in self.libraries if lib["kind"] != "series"]
|
|
self.set_libraries(others + [{"name": n, "kind": "series"} for n in cleaned])
|
|
|
|
@property
|
|
def movie_libraries(self) -> list[str]:
|
|
"""Pool folders holding single-file movies (kind ``movie``)."""
|
|
return self.libraries_of_kind("movie") or [POOL_MOVIES]
|
|
|
|
@property
|
|
def libraries_accepting_movies(self) -> list[str]:
|
|
"""Libraries a movie can live in / be assigned to — kind movie or mixed."""
|
|
return self.libraries_of_kind("movie", "mixed") or [POOL_MOVIES]
|
|
|
|
def set_movie_libraries(self, names: list[str]) -> None:
|
|
"""Set the movie libraries, keeping the other kinds; never empty."""
|
|
cleaned = [n.strip() for n in names if n.strip()] or [POOL_MOVIES]
|
|
others = [lib for lib in self.libraries if lib["kind"] != "movie"]
|
|
self.set_libraries([{"name": n, "kind": "movie"} for n in cleaned] + others)
|
|
|
|
def orphan_pool_folders(self) -> list[tuple[str, bool]]:
|
|
"""Top-level pool folders that aren't a configured library.
|
|
|
|
Returns ``(name, is_empty)`` pairs. Only these are candidates for cleanup;
|
|
non-empty ones are reported but never auto-deleted (they may hold media).
|
|
"""
|
|
pool = self.pool_dir
|
|
if not (pool and pool.is_dir()):
|
|
return []
|
|
known = {lib["name"] for lib in self.libraries}
|
|
orphans: list[tuple[str, bool]] = []
|
|
for entry in sorted(pool.iterdir()):
|
|
if entry.is_dir() and entry.name not in known:
|
|
orphans.append((entry.name, not any(entry.iterdir())))
|
|
return orphans
|
|
|
|
def remove_empty_pool_folder(self, name: str) -> bool:
|
|
"""Delete an **empty** orphan folder in the pool root. Returns True if removed."""
|
|
pool = self.pool_dir
|
|
if not pool:
|
|
return False
|
|
known = {lib["name"] for lib in self.libraries}
|
|
target = pool / name
|
|
if target.is_dir() and name not in known and not any(target.iterdir()):
|
|
target.rmdir()
|
|
return True
|
|
return False
|
|
|
|
def library_of(self, file_obj: File) -> str | None:
|
|
"""Which library (top-level pool folder) a pooled movie lives in."""
|
|
pool = self.pool_dir
|
|
if not pool:
|
|
return None
|
|
try:
|
|
return file_obj.file_path.relative_to(pool).parts[0]
|
|
except ValueError, IndexError:
|
|
return None
|
|
|
|
@property
|
|
def tag_schema(self) -> list[dict]:
|
|
"""Tag categories + ČSFD/Filmotéka rules (see config.DEFAULT_TAG_SCHEMA)."""
|
|
return self._get_setting("tag_schema")
|
|
|
|
def set_tag_schema(self, schema: list[dict]) -> None:
|
|
"""Set the tag schema and persist it."""
|
|
self._set_setting("tag_schema", schema)
|
|
|
|
def filmoteka_category_roots(self) -> dict[str, str]:
|
|
"""Category → output root-folder map derived from the tag schema.
|
|
|
|
Categories with ``filmoteka_root`` set to None are filterable but get no
|
|
folders in the generated tree.
|
|
"""
|
|
return {
|
|
e["category"]: e["filmoteka_root"]
|
|
for e in self.tag_schema
|
|
if e.get("filmoteka_root") is not None
|
|
}
|
|
|
|
def filmoteka_category_transforms(self) -> dict[str, str]:
|
|
"""Category → folder transform-name map (for grouping, e.g. rating bands).
|
|
|
|
Tags keep their exact value; this transform only shapes the folder name
|
|
in the generated tree. Categories without a transform are omitted.
|
|
"""
|
|
return {
|
|
e["category"]: e["transform"]
|
|
for e in self.tag_schema
|
|
if e.get("filmoteka_root") is not None and e.get("transform")
|
|
}
|
|
|
|
def filmoteka_category_filename_templates(self) -> dict[str, str]:
|
|
"""Category → hardlink-name template map (applied inside that category)."""
|
|
return {
|
|
e["category"]: e["filename_template"]
|
|
for e in self.tag_schema
|
|
if e.get("filmoteka_root") is not None and e.get("filename_template")
|
|
}
|
|
|
|
@property
|
|
def filmoteka_dir(self) -> Path | None:
|
|
value = self.global_config.get("filmoteka_dir")
|
|
return Path(value) if value else None
|
|
|
|
def set_pool_dir(self, pool_dir: Path) -> None:
|
|
"""Set the managed pool root and create every library's top-level folder."""
|
|
pool_dir = Path(pool_dir)
|
|
self.global_config["pool_dir"] = str(pool_dir)
|
|
save_global_config(self.global_config)
|
|
self._open_index(pool_dir) # settings now readable through the index
|
|
for lib in self.libraries:
|
|
(pool_dir / lib["name"]).mkdir(parents=True, exist_ok=True)
|
|
|
|
def set_filmoteka_dir(self, filmoteka_dir: Path) -> None:
|
|
"""Set the Filmotéka output folder (generated hardlink tree)."""
|
|
self.global_config["filmoteka_dir"] = str(Path(filmoteka_dir))
|
|
save_global_config(self.global_config)
|
|
|
|
def load_pool_movies(self) -> None:
|
|
"""Reload the movie list from every movie library, via the pool index.
|
|
|
|
Scans each configured movie library (``movie_libraries``) recursively —
|
|
a movie may sit directly in the library or inside its own per-movie
|
|
folder. Curator's own metadata and the sidecars Jellyfin writes (NFO,
|
|
artwork, subtitles) are skipped.
|
|
"""
|
|
from .series import looks_like_series_folder
|
|
|
|
self.filelist = []
|
|
pool = self.pool_dir
|
|
if not pool:
|
|
return
|
|
|
|
self._open_index(pool)
|
|
for lib in self.libraries:
|
|
if lib["kind"] not in ("movie", "mixed"):
|
|
continue
|
|
lib_dir = pool / lib["name"]
|
|
if not lib_dir.is_dir():
|
|
continue
|
|
if lib["kind"] == "mixed":
|
|
# A mixed library keeps movies at the top level and series in
|
|
# subfolders — but a movie's *version* folder also sits one
|
|
# level down, in the exact same "Title (rok) [tmdbid]/" shape
|
|
# a series folder has. Tell them apart by content: a series
|
|
# always nests its episodes in a Season NN/Specials folder,
|
|
# a movie's version folder never does (see
|
|
# series.looks_like_series_folder). Only the latter's files
|
|
# count as movies here — load_series() reads the former.
|
|
candidates = [p for p in sorted(lib_dir.iterdir()) if p.is_file()]
|
|
for sub in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
|
|
if is_canonical_pool_stem(
|
|
sub.name
|
|
) and not looks_like_series_folder(sub):
|
|
candidates += [p for p in sorted(sub.iterdir()) if p.is_file()]
|
|
else:
|
|
candidates = list_files(lib_dir)
|
|
for each in candidates:
|
|
if each.name.endswith(METADATA_SUFFIXES):
|
|
continue
|
|
if each.suffix.lower() in SIDECAR_SUFFIXES:
|
|
continue
|
|
self.filelist.append(File(each, self.tagmanager, index=self.index))
|
|
|
|
def check_data_consistency(self) -> dict:
|
|
"""Compare the pool index against the actual files in the movie libraries.
|
|
|
|
Detects drift such as a movie file deleted directly (metadata left behind)
|
|
or a file dropped into the pool without importing (no metadata).
|
|
|
|
Returns a dict with:
|
|
ok — False if the pool isn't set (with 'error')
|
|
missing — indexed media files that no longer exist on disk
|
|
untracked — pool files (pool-relative) with no index entry
|
|
index_count / disk_count — totals for the summary
|
|
"""
|
|
pool = self.pool_dir
|
|
if not (pool and pool.is_dir()):
|
|
return {
|
|
"ok": False,
|
|
"error": "Pool není nastaven.",
|
|
"missing": [],
|
|
"untracked": [],
|
|
}
|
|
|
|
index = self.index or PoolIndex(pool)
|
|
# Records are keyed by movie identity, so the paths to compare are the
|
|
# media entries listed inside them.
|
|
index_keys = {
|
|
media["file"]
|
|
for record in index.all_movies().values()
|
|
for media in record.get("media", [])
|
|
if media.get("file")
|
|
}
|
|
|
|
disk_keys = set()
|
|
for lib in self.libraries:
|
|
if lib["kind"] not in ("movie", "mixed"):
|
|
continue
|
|
lib_dir = pool / lib["name"]
|
|
if not lib_dir.is_dir():
|
|
continue
|
|
files = (
|
|
[p for p in lib_dir.iterdir() if p.is_file()]
|
|
if lib["kind"] == "mixed"
|
|
else list_files(lib_dir)
|
|
)
|
|
for f in files:
|
|
if f.name.endswith(METADATA_SUFFIXES):
|
|
continue
|
|
if f.suffix.lower() in SIDECAR_SUFFIXES:
|
|
continue
|
|
disk_keys.add(f.relative_to(pool).as_posix())
|
|
|
|
missing = sorted(k for k in index_keys if not (pool / k).is_file())
|
|
untracked = sorted(disk_keys - index_keys)
|
|
return {
|
|
"ok": True,
|
|
"missing": missing,
|
|
"untracked": untracked,
|
|
"index_count": len(index_keys),
|
|
"disk_count": len(disk_keys),
|
|
}
|
|
|
|
def verify_against_jellyfin(self, jf_movies: list) -> dict:
|
|
"""Compare the pool against what Jellyfin currently sees.
|
|
|
|
Matches each pooled movie to a Jellyfin item by **TMDb id** (from the
|
|
filename), falling back to (title, year). Reports what is out of sync.
|
|
|
|
``jf_movies`` is a list of ``jellyfin.JellyfinMovie``. Returns a dict:
|
|
matched — how many pooled movies Jellyfin has
|
|
pool_only — pooled filenames Jellyfin does not see (rescan/rematch)
|
|
jellyfin_only — Jellyfin titles with no matching pooled movie
|
|
pool_total / jellyfin_total — totals for the summary
|
|
"""
|
|
by_tmdb = {m.tmdb_id: m for m in jf_movies if m.tmdb_id}
|
|
by_name = {(m.title.strip().lower(), m.year): m for m in jf_movies if m.title}
|
|
|
|
matched_ids: set = set()
|
|
pool_only: list[str] = []
|
|
for f in self.filelist:
|
|
tmdb_id = parse_tmdb_id(f.file_path.stem)
|
|
ctx = f.name_context()
|
|
title = str(f.title or ctx.get("title") or "").strip().lower()
|
|
year_str = str(ctx.get("year") or "")
|
|
year = int(year_str) if year_str.isdigit() else None
|
|
|
|
match = None
|
|
if tmdb_id is not None and tmdb_id in by_tmdb:
|
|
match = by_tmdb[tmdb_id]
|
|
elif (title, year) in by_name:
|
|
match = by_name[(title, year)]
|
|
|
|
if match is not None:
|
|
matched_ids.add(match.jellyfin_id)
|
|
else:
|
|
pool_only.append(f.filename)
|
|
|
|
jellyfin_only = sorted(
|
|
m.title for m in jf_movies if m.jellyfin_id not in matched_ids
|
|
)
|
|
return {
|
|
"matched": len(self.filelist) - len(pool_only),
|
|
"pool_only": sorted(pool_only),
|
|
"jellyfin_only": jellyfin_only,
|
|
"pool_total": len(self.filelist),
|
|
"jellyfin_total": len(jf_movies),
|
|
}
|
|
|
|
def scan_video_integrity(
|
|
self,
|
|
files: list[File],
|
|
deep: bool = True,
|
|
on_progress: Callable[[int, int, File], None] | None = None,
|
|
should_cancel: Callable[[], bool] | None = None,
|
|
) -> dict:
|
|
"""Check a list of movies for corrupted/unreadable video data.
|
|
|
|
Runs :func:`check_video_integrity` on each file (deep = full ffmpeg
|
|
decode, otherwise a quick ffprobe probe). ``on_progress(done, total,
|
|
file)`` is called after each file and ``should_cancel()`` is polled
|
|
before each — so a GUI can show progress and stop between files.
|
|
|
|
Returns a dict with:
|
|
ok — False if ffmpeg/ffprobe isn't available (with 'error')
|
|
problems — IntegrityResult list for files that failed (bad or
|
|
unable-to-check), in scan order
|
|
checked — how many files were actually processed
|
|
cancelled — True if the scan stopped early
|
|
"""
|
|
if not ffmpeg_available(deep):
|
|
tool = "ffmpeg" if deep else "ffprobe"
|
|
return {
|
|
"ok": False,
|
|
"error": f"{tool} není k dispozici (nainstaluj FFmpeg).",
|
|
"problems": [],
|
|
"checked": 0,
|
|
"cancelled": False,
|
|
}
|
|
|
|
problems: list[IntegrityResult] = []
|
|
total = len(files)
|
|
checked = 0
|
|
cancelled = False
|
|
for i, f in enumerate(files, 1):
|
|
if should_cancel is not None and should_cancel():
|
|
cancelled = True
|
|
break
|
|
result = check_video_integrity(f.file_path, deep=deep)
|
|
checked += 1
|
|
if not result.ok:
|
|
problems.append(result)
|
|
if on_progress is not None:
|
|
on_progress(i, total, f)
|
|
return {
|
|
"ok": True,
|
|
"problems": problems,
|
|
"checked": checked,
|
|
"cancelled": cancelled,
|
|
}
|
|
|
|
def statistics(self) -> dict:
|
|
"""Aggregate stats over the loaded pool movies for the stats view.
|
|
|
|
Returns a dict with totals and, per tag category, the tag counts sorted
|
|
by frequency (descending).
|
|
"""
|
|
files = self.filelist
|
|
total_size = 0
|
|
with_nfo = 0
|
|
untagged = 0
|
|
ratings: list[int] = []
|
|
categories: dict[str, Counter] = {}
|
|
|
|
for f in files:
|
|
try:
|
|
total_size += f.file_path.stat().st_size
|
|
except OSError:
|
|
pass
|
|
if f.meta is not None:
|
|
with_nfo += 1
|
|
if not f.tags:
|
|
untagged += 1
|
|
for tag in f.tags:
|
|
categories.setdefault(tag.category, Counter())[tag.name] += 1
|
|
rating = (f.meta or {}).get("rating")
|
|
if isinstance(rating, (int, float)):
|
|
ratings.append(rating)
|
|
|
|
return {
|
|
"count": len(files),
|
|
"total_size": total_size,
|
|
"with_nfo": with_nfo,
|
|
"without_nfo": len(files) - with_nfo,
|
|
"untagged": untagged,
|
|
"avg_rating": round(sum(ratings) / len(ratings), 1) if ratings else None,
|
|
"categories": {
|
|
cat: counter.most_common() for cat, counter in categories.items()
|
|
},
|
|
}
|
|
|
|
def _identity_stem(self, file_obj: File) -> str:
|
|
"""The movie-identity stem ``file_obj`` belongs to.
|
|
|
|
A version inside a movie folder belongs to the folder's name (its own
|
|
filename carries the version label, which is not part of identity); a
|
|
flat file belongs to its own stem minus any ``.3D.<FORMAT>`` tag.
|
|
"""
|
|
if self.is_movie_group_member(file_obj):
|
|
return file_obj.file_path.parent.name
|
|
return re.sub(
|
|
r"\.3D\.(?:HSBS|FSBS|HTAB|FTAB|MVC)$",
|
|
"",
|
|
file_obj.file_path.stem,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
|
|
def pooled_with_stem(self, title: str) -> list[File]:
|
|
"""Pooled files of the movie identity ``title`` (case-insensitive) — a
|
|
flat file, every version of a grouped movie, or a 3D-tagged copy of
|
|
either. A film already in the pool must be found however it's stored,
|
|
or importing it again silently creates a second, root-level copy."""
|
|
key = title.strip().lower()
|
|
return [f for f in self.filelist if self._identity_stem(f).lower() == key]
|
|
|
|
def _evict(self, file_obj: File) -> None:
|
|
"""Delete a pooled movie: its metadata, the file, and the list entry."""
|
|
file_obj.delete_metadata()
|
|
if file_obj.file_path.exists():
|
|
file_obj.file_path.unlink()
|
|
if file_obj in self.filelist:
|
|
self.filelist.remove(file_obj)
|
|
|
|
def import_movie(
|
|
self,
|
|
source: Path,
|
|
title: str,
|
|
move: bool = False,
|
|
on_conflict: str = "suffix",
|
|
year: int | str | None = None,
|
|
tmdb_id: int | str | None = None,
|
|
library: str | None = None,
|
|
csfd_url: str | None = None,
|
|
format_3d: str | None = None,
|
|
version_label: str | None = None,
|
|
) -> File | None:
|
|
"""Bring a video file into a movie library and index its metadata.
|
|
|
|
Lands in ``pool/<library>/`` (``library`` defaults to the first movie
|
|
library, "Filmy"). The pooled filename follows the naming convention
|
|
``Title (YYYY) [tmdbid-N].ext`` when a ``year`` is given (and a
|
|
``tmdb_id`` when known — the form Jellyfin matches on); without a year it
|
|
falls back to ``Title.ext``. The GUI import always supplies a year, so a
|
|
library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``.
|
|
``format_3d`` (one of :data:`naming.THREE_D_FORMATS`) appends the
|
|
``.3D.<FORMAT>`` tag right away, when the file is already a 3D rip.
|
|
``on_conflict="version"`` adds the file as another version of a film
|
|
already in the pool instead (its popisek is ``version_label``, else the
|
|
``format_3d`` tag, else plain/default) — the file is never placed beside
|
|
that film as a second movie.
|
|
|
|
By default the original is **copied** (non-destructive). With ``move=True``
|
|
the source file is moved into the pool instead, leaving nothing behind.
|
|
|
|
``on_conflict`` decides what happens when a pooled movie of the same name
|
|
already exists:
|
|
|
|
- ``"suffix"`` (default): keep both, the new file gets a ``_N`` suffix.
|
|
- ``"replace"``: evict the existing same-named movie(s) (file + metadata)
|
|
and import the new one under the plain name.
|
|
- ``"skip"``: do not import; return ``None``.
|
|
|
|
Raises:
|
|
ValueError: a ``year`` was given but is not a four-digit number.
|
|
"""
|
|
pool = self.pool_dir
|
|
if pool is None:
|
|
raise RuntimeError("Pool není nastaven.")
|
|
lib = (library or "").strip() or self.movie_libraries[0]
|
|
movies = pool / lib
|
|
movies.mkdir(parents=True, exist_ok=True)
|
|
|
|
if self.index is None:
|
|
self._open_index(pool)
|
|
|
|
source = Path(source)
|
|
safe_title = title.strip() or source.stem
|
|
has_year = year is not None and str(year).strip() != ""
|
|
identity = (
|
|
canonical_pool_stem(safe_title, year, tmdb_id) if has_year else safe_title
|
|
)
|
|
stem = (
|
|
canonical_pool_stem(safe_title, year, tmdb_id, format_3d)
|
|
if has_year
|
|
else safe_title
|
|
)
|
|
target = movies / f"{stem}{source.suffix}"
|
|
|
|
existing = self.pooled_with_stem(identity)
|
|
if existing and on_conflict == "version":
|
|
# Same film already in the pool — the new file is one more version
|
|
# of it (in its folder), never a second movie beside it.
|
|
if version_label is None:
|
|
version_label = f"3D {format_3d.upper()}" if format_3d else ""
|
|
return self.add_movie_version(existing[0], source, version_label, move=move)
|
|
conflict = bool(existing) or target.exists() or (movies / identity).is_dir()
|
|
|
|
if conflict and on_conflict == "skip":
|
|
return None
|
|
if conflict and on_conflict == "replace":
|
|
# A grouped movie goes as a whole — removing one of its versions
|
|
# would leave the rest behind in a folder of their own.
|
|
for f in existing:
|
|
if f.file_path.exists():
|
|
self.remove_movie(f)
|
|
if target.exists():
|
|
target.unlink()
|
|
else:
|
|
# "suffix": never clobber an existing exact filename or folder
|
|
candidate = stem
|
|
counter = 1
|
|
while (movies / f"{candidate}{source.suffix}").exists() or (
|
|
movies / candidate
|
|
).is_dir():
|
|
candidate = f"{stem}_{counter}"
|
|
counter += 1
|
|
target = movies / f"{candidate}{source.suffix}"
|
|
|
|
if move:
|
|
shutil.move(str(source), str(target))
|
|
else:
|
|
shutil.copy2(source, target)
|
|
|
|
file_obj = File(target, self.tagmanager, index=self.index)
|
|
file_obj.title = safe_title
|
|
file_obj.csfd_url = csfd_url or None
|
|
file_obj.added = datetime.now().isoformat(timespec="seconds")
|
|
file_obj.save_metadata()
|
|
|
|
self.filelist.append(file_obj)
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return file_obj
|
|
|
|
def rename_movie(self, file_obj: File, new_title: str) -> File:
|
|
"""Rename a pooled movie's file to ``<new_title>.<ext>`` and reindex it.
|
|
|
|
Renames the physical file in pool/Filmy (keeping its extension), moves
|
|
the metadata to the new key, and syncs ``title``/``filename``. The
|
|
extension is preserved; ``new_title`` is the bare name without it.
|
|
|
|
Raises:
|
|
ValueError: empty name, a name containing a path separator, or
|
|
``file_obj`` is one version of a multi-version movie (renaming
|
|
just one file would orphan its siblings).
|
|
FileExistsError: another pooled file already uses that name.
|
|
"""
|
|
new_title = new_title.strip()
|
|
if not new_title:
|
|
raise ValueError("Název nesmí být prázdný.")
|
|
if "/" in new_title or "\\" in new_title:
|
|
raise ValueError("Název nesmí obsahovat lomítka.")
|
|
if self.is_movie_group_member(file_obj):
|
|
raise ValueError(
|
|
"Film s více verzemi zatím nelze takhle přejmenovat — "
|
|
"odeber verzi nebo uprav soubory ve složce filmu ručně."
|
|
)
|
|
|
|
old_path = file_obj.file_path
|
|
new_path = old_path.with_name(f"{new_title}{old_path.suffix}")
|
|
if new_path == old_path:
|
|
return file_obj # no change
|
|
|
|
if new_path.exists():
|
|
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
|
|
|
|
old_path.rename(new_path)
|
|
subtitles.rename_subtitles_with(old_path, new_path)
|
|
file_obj.relocate(new_path)
|
|
file_obj.title = new_title
|
|
file_obj.save_metadata()
|
|
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return file_obj
|
|
|
|
def _canonical_name_for(
|
|
self, file_obj: File, *, include_3d: bool = True
|
|
) -> str | None:
|
|
"""The ``Title (YYYY) [tmdbid-N]`` stem this movie should have, or ``None``.
|
|
|
|
Uses the clean title and the year from the movie's metadata
|
|
(``name_context``: cache → ``Rok`` tag → the current filename). A
|
|
``(YYYY)`` that leaked into the title is stripped so it isn't doubled, and
|
|
an existing ``[tmdbid-N]`` / ``.3D.<FORMAT>`` on the current filename is
|
|
preserved (``include_3d=False`` drops the 3D tag — used for a movie-group
|
|
**folder** name, where 3D becomes a per-version label instead). Returns
|
|
``None`` when no four-digit year is known.
|
|
"""
|
|
ctx = file_obj.name_context()
|
|
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
|
|
year = str(ctx.get("year") or "").strip()
|
|
|
|
parsed = parse_pool_stem(title)
|
|
if parsed is not None:
|
|
title = parsed[0]
|
|
year = year or str(parsed[1])
|
|
|
|
# Keep the TMDb id / 3D tag already in the pool filename, if any.
|
|
tmdb_id = parse_tmdb_id(file_obj.file_path.stem)
|
|
format_3d = parse_3d_format(file_obj.file_path.stem) if include_3d else None
|
|
|
|
title = title.replace("/", "-").replace("\\", "-").strip()
|
|
if not (title and is_valid_year(year)):
|
|
return None
|
|
return canonical_pool_stem(title, year, tmdb_id, format_3d)
|
|
|
|
def rename_to_canonical(self, file_obj: File) -> File | None:
|
|
"""Rename a pooled movie to ``Title (YYYY).ext`` from its metadata.
|
|
|
|
The year comes from the movie's metadata (NFO cache, falling back to a
|
|
``Rok`` tag or the existing filename). ``file_obj.title`` keeps the title
|
|
(without the year). Does not fire ``on_files_changed`` — the caller
|
|
refreshes once after a batch. A movie with multiple versions is skipped
|
|
(``None``) — renaming one version alone would orphan its siblings; use
|
|
the version tools in *Upravit film* instead.
|
|
|
|
Returns:
|
|
The file when renamed (or already canonical); ``None`` when no
|
|
four-digit year is known (or the movie has multiple versions), so
|
|
no canonical name can be built.
|
|
|
|
Raises:
|
|
FileExistsError: another pooled file already uses that name.
|
|
"""
|
|
if self.is_movie_group_member(file_obj):
|
|
return None
|
|
new_stem = self._canonical_name_for(file_obj)
|
|
if new_stem is None:
|
|
return None
|
|
|
|
old_path = file_obj.file_path
|
|
new_path = old_path.with_name(f"{new_stem}{old_path.suffix}")
|
|
if new_path == old_path:
|
|
return file_obj # already canonical
|
|
if new_path.exists():
|
|
raise FileExistsError(f"Soubor „{new_path.name}“ už v poolu existuje.")
|
|
|
|
parsed = parse_pool_stem(new_stem)
|
|
old_path.rename(new_path)
|
|
subtitles.rename_subtitles_with(old_path, new_path)
|
|
file_obj.relocate(new_path)
|
|
if parsed is not None:
|
|
file_obj.title = parsed[0]
|
|
file_obj.save_metadata()
|
|
return file_obj
|
|
|
|
def rename_all_to_canonical(
|
|
self,
|
|
files: list[File],
|
|
on_progress: Callable[[int, int, File], None] | None = None,
|
|
should_cancel: Callable[[], bool] | None = None,
|
|
) -> dict:
|
|
"""Rename each movie to ``Title (YYYY).ext`` from its metadata.
|
|
|
|
Skips files already canonical and those without a known year. Reports
|
|
conflicts (target name taken) without aborting the rest.
|
|
|
|
Returns a dict with:
|
|
renamed — (old_name, new_name) pairs actually renamed
|
|
skipped_no_year — filenames with no four-digit year available
|
|
conflicts — (old_name, message) where the target name was taken
|
|
unchanged — count already in canonical form
|
|
cancelled — True if stopped early
|
|
"""
|
|
renamed: list[tuple[str, str]] = []
|
|
skipped_no_year: list[str] = []
|
|
conflicts: list[tuple[str, str]] = []
|
|
unchanged = 0
|
|
cancelled = False
|
|
total = len(files)
|
|
for i, f in enumerate(files, 1):
|
|
if should_cancel is not None and should_cancel():
|
|
cancelled = True
|
|
break
|
|
old_name = f.filename
|
|
try:
|
|
result = self.rename_to_canonical(f)
|
|
except FileExistsError as exc:
|
|
conflicts.append((old_name, str(exc)))
|
|
else:
|
|
if result is None:
|
|
skipped_no_year.append(old_name)
|
|
elif f.filename != old_name:
|
|
renamed.append((old_name, f.filename))
|
|
else:
|
|
unchanged += 1
|
|
if on_progress is not None:
|
|
on_progress(i, total, f)
|
|
|
|
if renamed and self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return {
|
|
"renamed": renamed,
|
|
"skipped_no_year": skipped_no_year,
|
|
"conflicts": conflicts,
|
|
"unchanged": unchanged,
|
|
"cancelled": cancelled,
|
|
}
|
|
|
|
def move_to_library(self, file_obj: File, new_library: str) -> File:
|
|
"""Move a pooled movie into another library (top-level pool folder).
|
|
|
|
Moves the file into ``pool/<new_library>/`` and re-indexes it under the
|
|
new key. Raises:
|
|
ValueError: the target isn't a known movie library, or ``file_obj``
|
|
is one version of a multi-version movie (moving just one file
|
|
would orphan its siblings).
|
|
FileExistsError: a file of that name already sits in the target.
|
|
"""
|
|
pool = self.pool_dir
|
|
if pool is None:
|
|
raise RuntimeError("Pool není nastaven.")
|
|
new_library = new_library.strip()
|
|
if new_library not in self.libraries_accepting_movies:
|
|
raise ValueError(f"Neznámá knihovna „{new_library}“.")
|
|
if self.is_movie_group_member(file_obj):
|
|
raise ValueError(
|
|
"Film s více verzemi zatím nelze takhle přesunout — "
|
|
"odeber verzi, nebo přesuň celou složku filmu ručně."
|
|
)
|
|
|
|
old_path = file_obj.file_path
|
|
dest_dir = pool / new_library
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
new_path = dest_dir / old_path.name
|
|
if new_path == old_path:
|
|
return file_obj # already there
|
|
if new_path.exists():
|
|
raise FileExistsError(
|
|
f"V knihovně „{new_library}“ už „{old_path.name}“ je."
|
|
)
|
|
|
|
shutil.move(str(old_path), str(new_path))
|
|
subtitles.rename_subtitles_with(old_path, new_path)
|
|
file_obj.relocate(new_path) # drops the old index key, rebinds to the new
|
|
file_obj.save_metadata()
|
|
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return file_obj
|
|
|
|
def apply_movie_edits(
|
|
self,
|
|
file_obj: File,
|
|
*,
|
|
title: str,
|
|
year: int | str | None = None,
|
|
tmdb_id: int | str | None = None,
|
|
library: str | None = None,
|
|
date: str | None = None,
|
|
attributes: dict | None = None,
|
|
tags: list[str] | None = None,
|
|
csfd_url: str | None = None,
|
|
format_3d: str | None = None,
|
|
) -> File:
|
|
"""Apply all editable parameters of a movie at once (rename/move + metadata).
|
|
|
|
Renames the file to ``Title (rok) [tmdbid].3D.<FORMAT>.ext`` (year/3D
|
|
permitting), moves it into ``library``, and sets ``title`` / ``date`` /
|
|
``attributes`` / ``tags`` (a full ``["Category/Name", …]`` list;
|
|
NFO-tag provenance is kept for the ones that remain). No-ops that don't
|
|
change the path skip the move.
|
|
|
|
``format_3d`` follows :func:`naming.canonical_pool_stem`: omitted
|
|
(``None``) keeps whatever 3D tag the file already has, an empty string
|
|
clears it, one of :data:`naming.THREE_D_FORMATS` sets it.
|
|
|
|
Raises:
|
|
FileExistsError: the target name is already taken.
|
|
"""
|
|
pool = self.pool_dir
|
|
if pool is None:
|
|
raise RuntimeError("Pool není nastaven.")
|
|
title = (title or "").strip() or file_obj.file_path.stem
|
|
year_s = str(year).strip() if year not in (None, "") else ""
|
|
grouped = self.is_movie_group_member(file_obj)
|
|
if format_3d is None:
|
|
format_3d = None if grouped else parse_3d_format(file_obj.file_path.stem)
|
|
stem = (
|
|
canonical_pool_stem(title, year_s, tmdb_id, format_3d)
|
|
if is_valid_year(year_s)
|
|
else title
|
|
)
|
|
lib = (
|
|
(library or "").strip()
|
|
or self.library_of(file_obj)
|
|
or self.movie_libraries[0]
|
|
)
|
|
old_path = file_obj.file_path
|
|
|
|
if grouped:
|
|
# Identity is locked to the container folder's name; only a
|
|
# no-op (title/year/tmdb_id/library all unchanged) is allowed
|
|
# here — metadata-only fields below still apply normally.
|
|
if stem != old_path.parent.name or lib != self.library_of(file_obj):
|
|
raise ValueError(
|
|
"Film s více verzemi zatím nelze takhle přejmenovat/přesunout "
|
|
"(rok/název/TMDb ID/knihovna) — odeber verzi, nebo uprav "
|
|
"soubory ve složce filmu ručně. Datum/atributy/tagy/ČSFD "
|
|
"odkaz upravit lze."
|
|
)
|
|
else:
|
|
new_path = pool / lib / f"{stem}{old_path.suffix}"
|
|
if new_path != old_path:
|
|
if new_path.exists():
|
|
raise FileExistsError(f"Soubor „{new_path.name}“ už existuje.")
|
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(str(old_path), str(new_path))
|
|
subtitles.rename_subtitles_with(old_path, new_path)
|
|
file_obj.relocate(new_path)
|
|
|
|
file_obj.title = title
|
|
if date is not None:
|
|
file_obj.date = date or None
|
|
if csfd_url is not None:
|
|
file_obj.csfd_url = csfd_url or None
|
|
if attributes is not None:
|
|
file_obj.attributes = dict(attributes)
|
|
if tags is not None:
|
|
new_tags: list = []
|
|
seen: set[str] = set()
|
|
for full_path in tags:
|
|
full_path = full_path.strip()
|
|
if "/" not in full_path or full_path in seen:
|
|
continue
|
|
cat, name = full_path.split("/", 1)
|
|
new_tags.append(self.tagmanager.add_tag(cat.strip(), name.strip()))
|
|
seen.add(full_path)
|
|
file_obj.tags = new_tags
|
|
file_obj.auto_tag_paths = {p for p in file_obj.auto_tag_paths if p in seen}
|
|
|
|
file_obj.save_metadata()
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return file_obj
|
|
|
|
# ------------------------------------------------------------------
|
|
# Movie versions (Jellyfin "multiple versions": 2D + 3D, 4K + 1080p, …)
|
|
# ------------------------------------------------------------------
|
|
#
|
|
# A movie is identified by (title, year, tmdbid) — the canonical stem. As
|
|
# long as it has exactly one video file, it stays flat:
|
|
# pool/<Library>/Title (rok) [tmdbid].ext
|
|
# The moment a second version is added, Curator promotes it to its own
|
|
# folder (Jellyfin's own "multiple versions" convention — every file in it
|
|
# must start with the folder's exact name):
|
|
# pool/<Library>/Title (rok) [tmdbid]/
|
|
# Title (rok) [tmdbid] - Výchozí.ext
|
|
# Title (rok) [tmdbid] - 3D HSBS.ext
|
|
# Shared metadata (tags, ČSFD odkaz, datum, atributy) lives on whichever
|
|
# version is alphabetically first (Jellyfin's own "default version" rule —
|
|
# `visible_movies` shows only that one in the table). Renaming/moving/
|
|
# retitling a grouped movie is not supported yet (see the guards in
|
|
# `rename_movie` / `move_to_library` / `apply_movie_edits`) — remove a
|
|
# version (auto-demotes back to flat when one is left) or edit the files
|
|
# directly if you need that.
|
|
|
|
def is_movie_group_member(self, file_obj: File) -> bool:
|
|
"""True when ``file_obj`` is one version inside its own per-movie folder.
|
|
|
|
Recognised in ``movie`` and ``mixed`` libraries. A ``mixed`` library's
|
|
subfolder can be either a movie's version folder or a series — both
|
|
use the identical ``Title (rok) [tmdbid]/`` shape — so there it also
|
|
checks the folder holds no ``Season NN``/``Specials`` subfolder (the
|
|
shape Curator always gives a series; a movie's version folder never
|
|
has one, its files sit flat inside it — see
|
|
:func:`series.looks_like_series_folder`).
|
|
"""
|
|
from .series import looks_like_series_folder
|
|
|
|
pool = self.pool_dir
|
|
lib = self.library_of(file_obj)
|
|
if pool is None or lib is None or lib not in self.libraries_accepting_movies:
|
|
return False
|
|
parent = file_obj.file_path.parent
|
|
if parent == pool / lib or not is_canonical_pool_stem(parent.name):
|
|
return False
|
|
return not (
|
|
lib in self.libraries_of_kind("mixed") and looks_like_series_folder(parent)
|
|
)
|
|
|
|
def list_movie_versions(self, file_obj: File) -> list[File]:
|
|
"""Every version sharing ``file_obj``'s movie-group folder, name-sorted.
|
|
|
|
A single-element list (just ``file_obj``) when it isn't grouped.
|
|
"""
|
|
if not self.is_movie_group_member(file_obj):
|
|
return [file_obj]
|
|
folder = file_obj.file_path.parent
|
|
versions = []
|
|
for p in sorted(folder.iterdir()):
|
|
if (
|
|
not p.is_file()
|
|
or p.suffix.lower() in SIDECAR_SUFFIXES
|
|
or p.name.endswith(METADATA_SUFFIXES)
|
|
):
|
|
continue
|
|
existing = next((f for f in self.filelist if f.file_path == p), None)
|
|
versions.append(existing or File(p, self.tagmanager, index=self.index))
|
|
return versions
|
|
|
|
def primary_version(self, versions: list[File]) -> File:
|
|
"""Which version represents the movie in the table.
|
|
|
|
Metadata is shared by the whole movie record now, so this is purely a
|
|
display choice: the plain/unlabelled file first, then the one labelled
|
|
:data:`movie.DEFAULT_VERSION_LABEL`, then alphabetically. Unrelated to
|
|
which version Jellyfin plays by default (its own, alphabetical rule).
|
|
"""
|
|
from .movie import DEFAULT_VERSION_LABEL
|
|
|
|
def rank(f: File) -> tuple[int, str]:
|
|
label = f._identity_from_path()[4]
|
|
order = 0 if not label else (1 if label == DEFAULT_VERSION_LABEL else 2)
|
|
return order, f.file_path.name.casefold()
|
|
|
|
return min(versions, key=rank)
|
|
|
|
def visible_movies(self) -> list[File]:
|
|
"""``filelist`` collapsed to one row per movie for the table.
|
|
|
|
Ungrouped files pass through unchanged; a movie-group folder
|
|
contributes only its :meth:`primary_version` — the rest are reachable
|
|
via *Upravit film → Verze*, not as separate rows.
|
|
"""
|
|
seen_folders: set[Path] = set()
|
|
result: list[File] = []
|
|
for f in self.filelist:
|
|
if not self.is_movie_group_member(f):
|
|
result.append(f)
|
|
continue
|
|
folder = f.file_path.parent
|
|
if folder in seen_folders:
|
|
continue
|
|
seen_folders.add(folder)
|
|
result.append(self.primary_version(self.list_movie_versions(f)))
|
|
return result
|
|
|
|
def add_movie_version(
|
|
self,
|
|
file_obj: File,
|
|
source: Path,
|
|
label: str,
|
|
*,
|
|
move: bool = False,
|
|
existing_label: str | None = None,
|
|
) -> File:
|
|
"""Add ``source`` as another playable version of ``file_obj``'s movie.
|
|
|
|
Promotes a flat movie to its own per-movie folder on the first extra
|
|
version — the pre-existing file is moved in as
|
|
``<folder> - {existing_label}.ext`` (only on this first promotion) and
|
|
``source`` lands as ``<folder> - {label}.ext``. A movie already grouped
|
|
just gets one more file in the same folder. Shared metadata stays
|
|
wherever it already was; the new version starts with none of its own
|
|
(attach subtitles / a 3D formát to it separately if needed).
|
|
|
|
An empty ``label`` (a plain "2D" version with nothing else to call it)
|
|
falls back to :data:`movie.DEFAULT_VERSION_LABEL` ("Výchozí") —
|
|
Jellyfin's own examples label every grouped file, so an unlabelled one
|
|
still needs *something*, just not something the user has to type.
|
|
Likewise ``existing_label`` left unset (``None``) is derived from the
|
|
pre-existing file's own name: its current ``.3D.<FORMAT>`` tag if it
|
|
has one — so promoting a movie that was flat-and-3D-only never
|
|
silently drops that on the floor — else the same "Výchozí" default.
|
|
|
|
Raises:
|
|
RuntimeError: no pool, or ``file_obj`` has no valid title+year yet.
|
|
ValueError: the folder would hold both parts and versions.
|
|
FileExistsError: a version of that name already exists.
|
|
"""
|
|
from .movie import DEFAULT_VERSION_LABEL
|
|
|
|
label = (label or "").strip() or DEFAULT_VERSION_LABEL
|
|
pool = self.pool_dir
|
|
if pool is None:
|
|
raise RuntimeError("Pool není nastaven.")
|
|
source = Path(source)
|
|
|
|
stem: str | None
|
|
if self.is_movie_group_member(file_obj):
|
|
folder = file_obj.file_path.parent
|
|
stem = folder.name
|
|
existing = [
|
|
v._identity_from_path()[4] or DEFAULT_VERSION_LABEL
|
|
for v in self.list_movie_versions(file_obj)
|
|
]
|
|
if _mixes_parts_and_versions([*existing, label]):
|
|
raise ValueError(_PARTS_AND_VERSIONS_MIX)
|
|
else:
|
|
stem = self._canonical_name_for(file_obj, include_3d=False)
|
|
if stem is None:
|
|
raise RuntimeError(
|
|
"Film nemá platný název/rok — nejde založit složku verzí."
|
|
)
|
|
if existing_label is None:
|
|
from .movie import normalize_part_label
|
|
|
|
fmt = parse_3d_format(file_obj.file_path.stem)
|
|
if normalize_part_label(label) is not None:
|
|
# the film is being split into parts — what's already
|
|
# here is the first one
|
|
existing_label = "part1"
|
|
else:
|
|
existing_label = f"3D {fmt}" if fmt else DEFAULT_VERSION_LABEL
|
|
if _mixes_parts_and_versions([existing_label, label]):
|
|
raise ValueError(_PARTS_AND_VERSIONS_MIX)
|
|
lib = self.library_of(file_obj) or self.movie_libraries[0]
|
|
folder = pool / lib / stem
|
|
if folder.exists():
|
|
raise FileExistsError(f"Složka „{stem}“ už v poolu existuje.")
|
|
folder.mkdir(parents=True)
|
|
old_path = file_obj.file_path
|
|
existing_target = folder / _version_filename(
|
|
stem, existing_label, old_path.suffix
|
|
)
|
|
shutil.move(str(old_path), str(existing_target))
|
|
subtitles.rename_subtitles_with(old_path, existing_target)
|
|
file_obj.relocate(existing_target)
|
|
file_obj.save_location()
|
|
|
|
target = folder / _version_filename(stem, label, source.suffix)
|
|
if target.exists():
|
|
raise FileExistsError(f"Verze „{label}“ už existuje.")
|
|
if move:
|
|
shutil.move(str(source), str(target))
|
|
else:
|
|
shutil.copy2(source, target)
|
|
new_version = File(target, self.tagmanager, index=self.index)
|
|
new_version.added = datetime.now().isoformat(timespec="seconds")
|
|
new_version.save_metadata()
|
|
self.filelist.append(new_version)
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return new_version
|
|
|
|
def add_movie_part(
|
|
self, file_obj: File, source: Path, *, move: bool = False
|
|
) -> File:
|
|
"""Add ``source`` as the next part of ``file_obj``'s film.
|
|
|
|
For a film split across several files (Jellyfin "stacking"): the parts
|
|
live in the film's folder as ``<folder>-part1.ext``, ``-part2.ext``, …
|
|
and Jellyfin plays them back to back as one movie. A flat film becomes
|
|
``part1`` and ``source`` ``part2``; a film that already has parts gets
|
|
the next free number.
|
|
|
|
Raises:
|
|
ValueError: the film is grouped as *versions* — parts and versions
|
|
can't be combined in one folder yet.
|
|
RuntimeError / FileExistsError: see :meth:`add_movie_version`.
|
|
"""
|
|
from .movie import normalize_part_label, part_label
|
|
|
|
if not self.is_movie_group_member(file_obj):
|
|
return self.add_movie_version(file_obj, source, part_label(2), move=move)
|
|
numbers = [
|
|
normalize_part_label(v._identity_from_path()[4])
|
|
for v in self.list_movie_versions(file_obj)
|
|
]
|
|
if any(n is None for n in numbers):
|
|
raise ValueError(
|
|
"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."
|
|
)
|
|
next_number = max(n for n in numbers if n is not None) + 1
|
|
return self.add_movie_version(
|
|
file_obj, source, part_label(next_number), move=move
|
|
)
|
|
|
|
def movie_group_mode(self, file_obj: File) -> str:
|
|
"""How ``file_obj``'s film is laid out: ``"flat"`` (one file in the
|
|
library root), ``"versions"`` (a folder of alternatives) or ``"parts"``
|
|
(a folder of pieces played back to back)."""
|
|
from .movie import normalize_part_label
|
|
|
|
if not self.is_movie_group_member(file_obj):
|
|
return "flat"
|
|
labels = [
|
|
v._identity_from_path()[4] for v in self.list_movie_versions(file_obj)
|
|
]
|
|
if labels and all(normalize_part_label(label) is not None for label in labels):
|
|
return "parts"
|
|
return "versions"
|
|
|
|
def ordered_media(self, file_obj: File) -> list[File]:
|
|
"""The film's files in display order — parts by number (``part2``
|
|
before ``part10``), versions with the default first, then by popisek."""
|
|
from .movie import DEFAULT_VERSION_LABEL, normalize_part_label
|
|
|
|
def key(f: File) -> tuple[int, int, str]:
|
|
label = f._identity_from_path()[4]
|
|
part = normalize_part_label(label)
|
|
if part is not None:
|
|
return 0, part, ""
|
|
if not label or label == DEFAULT_VERSION_LABEL:
|
|
return 0, 0, ""
|
|
return 1, 0, label.casefold()
|
|
|
|
versions = self.list_movie_versions(file_obj)
|
|
# the caller's own object stands in for its file, so it is the one
|
|
# that gets relocated by anything that renames the files
|
|
versions = [
|
|
file_obj if v.file_path == file_obj.file_path else v for v in versions
|
|
]
|
|
return sorted(versions, key=key)
|
|
|
|
def _relabel_movie_group(
|
|
self, file_obj: File, assignments: list[tuple[File, str]]
|
|
) -> None:
|
|
"""Give every file of ``file_obj``'s folder a new popisek at once.
|
|
|
|
``assignments`` must cover each file of the folder exactly once. The
|
|
renames go through temporary names first, so swapping two labels
|
|
(``part1`` ↔ ``part2``) never overwrites a file. Subtitles follow;
|
|
the shared movie record keeps its metadata.
|
|
|
|
Raises:
|
|
ValueError: the files don't match the folder, two would end up with
|
|
the same name, or parts and versions would be mixed.
|
|
"""
|
|
from .movie import DEFAULT_VERSION_LABEL
|
|
|
|
folder = file_obj.file_path.parent
|
|
current = {v.file_path for v in self.list_movie_versions(file_obj)}
|
|
if {f.file_path for f, _ in assignments} != current or len(assignments) != len(
|
|
current
|
|
):
|
|
raise ValueError(
|
|
"Přeřazení musí zahrnout každý soubor složky právě jednou."
|
|
)
|
|
labels = [
|
|
(label or "").strip() or DEFAULT_VERSION_LABEL for _, label in assignments
|
|
]
|
|
if _mixes_parts_and_versions(labels):
|
|
raise ValueError(_PARTS_AND_VERSIONS_MIX)
|
|
targets = [
|
|
folder / _version_filename(folder.name, label, f.file_path.suffix)
|
|
for (f, _), label in zip(assignments, labels)
|
|
]
|
|
if len({t.name.casefold() for t in targets}) != len(targets):
|
|
raise ValueError(
|
|
"Dva soubory by dostaly stejný název — popisky se musí lišit."
|
|
)
|
|
|
|
for i, (f, _) in enumerate(assignments):
|
|
old_path = f.file_path
|
|
temp = folder / f"{folder.name} - .curator-tmp-{i}{old_path.suffix}"
|
|
old_path.rename(temp)
|
|
subtitles.rename_subtitles_with(old_path, temp)
|
|
f.relocate(temp)
|
|
for (f, _), target in zip(assignments, targets):
|
|
old_path = f.file_path
|
|
old_path.rename(target)
|
|
subtitles.rename_subtitles_with(old_path, target)
|
|
f.relocate(target)
|
|
f.save_location()
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
|
|
def move_movie_part(self, part: File, delta: int) -> None:
|
|
"""Move one part earlier (``delta < 0``) or later in its film's order.
|
|
|
|
The parts are renumbered ``part1``…``partN`` to match; moving past
|
|
either end does nothing.
|
|
|
|
Raises:
|
|
ValueError: the film isn't laid out as parts.
|
|
"""
|
|
from .movie import part_label
|
|
|
|
if self.movie_group_mode(part) != "parts":
|
|
raise ValueError("Film nemá díly, které by šlo přeřadit.")
|
|
order = self.ordered_media(part)
|
|
i = next(n for n, f in enumerate(order) if f.file_path == part.file_path)
|
|
j = i + delta
|
|
if not 0 <= j < len(order):
|
|
return
|
|
order[i], order[j] = order[j], order[i]
|
|
self._relabel_movie_group(
|
|
part, [(f, part_label(n + 1)) for n, f in enumerate(order)]
|
|
)
|
|
|
|
def set_movie_group_mode(
|
|
self,
|
|
file_obj: File,
|
|
mode: str,
|
|
labels: list[str] | None = None,
|
|
) -> None:
|
|
"""Switch a film's folder between ``"versions"`` and ``"parts"``.
|
|
|
|
To parts: the files are numbered ``part1``…``partN`` in their current
|
|
order (the default version first). To versions: the first part becomes
|
|
the default version (no popisek) and the others take ``labels``, one
|
|
per extra file, in order — free text such as "kinoverze" or a
|
|
``3D <FORMÁT>`` preset.
|
|
|
|
Raises:
|
|
ValueError: the film is flat, ``mode`` is unknown, or the labels are
|
|
missing, empty, duplicate or look like part labels.
|
|
"""
|
|
from .movie import DEFAULT_VERSION_LABEL, part_label
|
|
|
|
if mode not in ("versions", "parts"):
|
|
raise ValueError(f"Neznámý režim „{mode}“.")
|
|
current = self.movie_group_mode(file_obj)
|
|
if current == "flat":
|
|
raise ValueError(
|
|
"Film má jen jeden soubor — režim složky zatím nemá co měnit."
|
|
)
|
|
if current == mode:
|
|
return
|
|
order = self.ordered_media(file_obj)
|
|
if mode == "parts":
|
|
self._relabel_movie_group(
|
|
file_obj, [(f, part_label(n + 1)) for n, f in enumerate(order)]
|
|
)
|
|
return
|
|
clean = [(label or "").strip() for label in (labels or [])]
|
|
if len(clean) != len(order) - 1 or not all(clean):
|
|
raise ValueError(
|
|
f"Každý další soubor potřebuje popisek verze ({len(order) - 1})."
|
|
)
|
|
if any(label.casefold() == DEFAULT_VERSION_LABEL.casefold() for label in clean):
|
|
raise ValueError(
|
|
f"„{DEFAULT_VERSION_LABEL}“ je vyhrazený pro první soubor."
|
|
)
|
|
if len({label.casefold() for label in clean}) != len(clean):
|
|
raise ValueError("Popisky verzí se musí lišit.")
|
|
self._relabel_movie_group(file_obj, [(order[0], ""), *zip(order[1:], clean)])
|
|
|
|
def rename_movie_version(self, file_obj: File, new_label: str) -> File:
|
|
"""Rename one version's label within its movie's version folder.
|
|
|
|
Only meaningful once a movie is grouped — a flat (single-version)
|
|
movie has no on-disk label to rename; give it a second version first
|
|
(its sole version keeps showing as "Výchozí" in *Upravit film* either
|
|
way, that's just a placeholder for the table).
|
|
|
|
An empty ``new_label`` falls back to :data:`movie.DEFAULT_VERSION_LABEL`
|
|
("Výchozí"), same as :meth:`add_movie_version`.
|
|
|
|
Raises:
|
|
ValueError: ``file_obj`` isn't part of a version group, or the
|
|
folder would end up holding both parts and versions.
|
|
FileExistsError: another version already uses that label.
|
|
"""
|
|
from .movie import DEFAULT_VERSION_LABEL
|
|
|
|
if not self.is_movie_group_member(file_obj):
|
|
raise ValueError(
|
|
"Film nemá žádné další verze — popisek se uplatní až od druhé verze."
|
|
)
|
|
new_label = (new_label or "").strip() or DEFAULT_VERSION_LABEL
|
|
others = [
|
|
v._identity_from_path()[4] or DEFAULT_VERSION_LABEL
|
|
for v in self.list_movie_versions(file_obj)
|
|
if v.file_path != file_obj.file_path
|
|
]
|
|
if _mixes_parts_and_versions([*others, new_label]):
|
|
raise ValueError(_PARTS_AND_VERSIONS_MIX)
|
|
folder = file_obj.file_path.parent
|
|
target = folder / _version_filename(
|
|
folder.name, new_label, file_obj.file_path.suffix
|
|
)
|
|
old_path = file_obj.file_path
|
|
if target == old_path:
|
|
return file_obj
|
|
if target.exists():
|
|
raise FileExistsError(f"Verze „{new_label}“ už existuje.")
|
|
old_path.rename(target)
|
|
subtitles.rename_subtitles_with(old_path, target)
|
|
file_obj.relocate(target)
|
|
file_obj.save_location()
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return file_obj
|
|
|
|
def normalize_default_version_names(self) -> list[tuple[str, str]]:
|
|
"""Give a grouped movie's default version its bare name.
|
|
|
|
Pool files written before the default version went unlabelled still
|
|
read ``<folder> - Výchozí.ext``; this renames each to ``<folder>.ext``
|
|
(subtitles and metadata follow). A folder whose bare name is already
|
|
taken is left alone. Returns the ``(old, new)`` filenames renamed.
|
|
"""
|
|
from .movie import DEFAULT_VERSION_LABEL
|
|
|
|
renamed: list[tuple[str, str]] = []
|
|
for f in list(self.filelist):
|
|
if not self.is_movie_group_member(f):
|
|
continue
|
|
folder = f.file_path.parent
|
|
if f.file_path.stem != f"{folder.name} - {DEFAULT_VERSION_LABEL}":
|
|
continue
|
|
target = folder / f"{folder.name}{f.file_path.suffix}"
|
|
if target.exists():
|
|
continue
|
|
old_path = f.file_path
|
|
old_path.rename(target)
|
|
subtitles.rename_subtitles_with(old_path, target)
|
|
f.relocate(target)
|
|
f.save_location()
|
|
renamed.append((old_path.name, target.name))
|
|
if renamed and self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return renamed
|
|
|
|
def remove_movie_version(self, file_obj: File) -> None:
|
|
"""Remove one version (+ its subtitles) from a movie's version group.
|
|
|
|
When exactly one version is left afterward, the movie is demoted back
|
|
to a flat file — moved out to the library root under the bare
|
|
canonical stem, or ``stem.3D.<FORMAT>`` when that sole survivor's own
|
|
popisek was a 3D formát (so a movie that ends up 3D-only again keeps
|
|
saying so, instead of silently becoming a plain file) — and the
|
|
now-empty folder is removed. Metadata needs no rescuing: it lives on
|
|
the movie record, not on any one version, so removing even the
|
|
version shown in the table leaves it untouched.
|
|
|
|
Raises:
|
|
ValueError: ``file_obj`` isn't part of a version group (nothing to
|
|
remove it *from* — a lone movie has no "versions" to drop).
|
|
"""
|
|
from .movie import normalize_3d_label
|
|
|
|
if not self.is_movie_group_member(file_obj):
|
|
raise ValueError("Film nemá žádné další verze k odebrání.")
|
|
folder = file_obj.file_path.parent
|
|
file_obj.delete_metadata()
|
|
for sub in subtitles.find_subtitles(file_obj.file_path):
|
|
sub.path.unlink()
|
|
file_obj.file_path.unlink()
|
|
if file_obj in self.filelist:
|
|
self.filelist.remove(file_obj)
|
|
|
|
remaining = [
|
|
p
|
|
for p in folder.iterdir()
|
|
if p.is_file()
|
|
and p.suffix.lower() not in SIDECAR_SUFFIXES
|
|
and not p.name.endswith(METADATA_SUFFIXES)
|
|
]
|
|
if len(remaining) == 1:
|
|
sole = remaining[0]
|
|
sole_file = next((f for f in self.filelist if f.file_path == sole), None)
|
|
prefix = f"{folder.name} - "
|
|
label = sole.stem[len(prefix) :] if sole.stem.startswith(prefix) else ""
|
|
fmt = normalize_3d_label(label)
|
|
new_stem = f"{folder.name}.3D.{fmt}" if fmt else folder.name
|
|
new_path = folder.parent / f"{new_stem}{sole.suffix}"
|
|
sole.rename(new_path)
|
|
subtitles.rename_subtitles_with(sole, new_path)
|
|
if sole_file is not None:
|
|
sole_file.relocate(new_path)
|
|
sole_file.save_location()
|
|
shutil.rmtree(folder, ignore_errors=True)
|
|
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
|
|
def remove_movie(self, file_obj: File) -> None:
|
|
"""Delete a whole movie from the pool — every version, subtitle and record.
|
|
|
|
For a flat movie that is just its file (+ subtitles); for a grouped one
|
|
it is the entire per-movie folder, so removing the row shown in the
|
|
table never leaves the other versions behind as a half-empty group.
|
|
"""
|
|
versions = self.list_movie_versions(file_obj)
|
|
grouped = self.is_movie_group_member(file_obj)
|
|
for version in versions:
|
|
version.delete_metadata()
|
|
for sub in subtitles.find_subtitles(version.file_path):
|
|
sub.path.unlink(missing_ok=True)
|
|
version.file_path.unlink(missing_ok=True)
|
|
if version in self.filelist:
|
|
self.filelist.remove(version)
|
|
if grouped:
|
|
shutil.rmtree(file_obj.file_path.parent, ignore_errors=True)
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
|
|
def _movie_units(self) -> list[list[File]]:
|
|
"""``filelist`` grouped per movie: one list per flat file / version folder."""
|
|
units: dict[Path, list[File]] = {}
|
|
for f in self.filelist:
|
|
anchor = (
|
|
f.file_path.parent if self.is_movie_group_member(f) else f.file_path
|
|
)
|
|
units.setdefault(anchor, []).append(f)
|
|
return list(units.values())
|
|
|
|
def find_duplicates(self) -> list[dict]:
|
|
"""Movies that show up in the pool more than once.
|
|
|
|
Each movie counts once however many versions it has (a version folder
|
|
is one movie, not a duplicate of itself). Two movies are flagged when
|
|
they share
|
|
|
|
* the same TMDb id — e.g. ``X.mkv`` and ``X.3D.HSBS.mkv`` lying flat
|
|
side by side instead of grouped, or the same film in two libraries;
|
|
* the same title + year — typically one copy paired with TMDb and one
|
|
not (or paired with a different id);
|
|
* a byte-identical file size — almost certainly the same file copied.
|
|
|
|
Overlapping hits are merged into one group. Returns a list of
|
|
``{"movies": [File, …], "reasons": [str, …]}`` — ``movies`` holds the
|
|
:meth:`primary_version` of each movie — sorted by title.
|
|
"""
|
|
units = self._movie_units()
|
|
reps = [self.primary_version(u) for u in units]
|
|
|
|
parent = list(range(len(units)))
|
|
|
|
def find(i: int) -> int:
|
|
while parent[i] != i:
|
|
parent[i] = parent[parent[i]]
|
|
i = parent[i]
|
|
return i
|
|
|
|
reasons: dict[frozenset[int], set[str]] = {}
|
|
|
|
def flag(indices: list[int], reason: str) -> None:
|
|
if len(indices) < 2:
|
|
return
|
|
for i in indices[1:]:
|
|
parent[find(i)] = find(indices[0])
|
|
reasons.setdefault(frozenset(indices), set()).add(reason)
|
|
|
|
from .movie import identity_key
|
|
|
|
by_tmdb: dict[int, list[int]] = {}
|
|
by_name: dict[str, list[int]] = {}
|
|
by_size: dict[int, list[int]] = {}
|
|
tmdb_of: list[int | None] = []
|
|
for i, (unit, rep) in enumerate(zip(units, reps)):
|
|
_key, title, year, tmdb_id, _label = rep._identity_from_path()
|
|
tmdb_of.append(int(tmdb_id) if tmdb_id is not None else None)
|
|
if tmdb_id is not None:
|
|
by_tmdb.setdefault(int(tmdb_id), []).append(i)
|
|
if year and title:
|
|
by_name.setdefault(identity_key(None, title, year), []).append(i)
|
|
for f in unit:
|
|
try:
|
|
size = f.file_path.stat().st_size
|
|
except OSError:
|
|
continue
|
|
if size > 0 and i not in by_size.get(size, []):
|
|
by_size.setdefault(size, []).append(i)
|
|
|
|
for tmdb_id, idx in by_tmdb.items():
|
|
flag(idx, f"stejné TMDb ID {tmdb_id}")
|
|
for idx in by_name.values():
|
|
ids = {tmdb_of[i] for i in idx}
|
|
if len(ids) == 1 and None not in ids:
|
|
continue # already reported as the same TMDb id
|
|
flag(idx, "stejný název a rok")
|
|
for idx in by_size.values():
|
|
flag(idx, "shodná velikost souboru")
|
|
|
|
groups: dict[int, dict] = {}
|
|
for members, why in reasons.items():
|
|
root = find(next(iter(members)))
|
|
group = groups.setdefault(root, {"indices": set(), "reasons": set()})
|
|
group["indices"] |= members
|
|
group["reasons"] |= why
|
|
|
|
result = []
|
|
for group in groups.values():
|
|
movies = sorted(
|
|
(reps[i] for i in group["indices"]),
|
|
key=lambda f: f.file_path.as_posix().casefold(),
|
|
)
|
|
result.append({"movies": movies, "reasons": sorted(group["reasons"])})
|
|
result.sort(
|
|
key=lambda g: (g["movies"][0].title or g["movies"][0].filename).casefold()
|
|
)
|
|
return result
|
|
|
|
def merge_as_version(
|
|
self,
|
|
keep: File,
|
|
other: File,
|
|
label: str,
|
|
*,
|
|
keep_label: str | None = None,
|
|
) -> File:
|
|
"""Fold the duplicate movie ``other`` into ``keep`` as one more version.
|
|
|
|
``other``'s file is *moved* into ``keep``'s version folder (promoting
|
|
``keep`` from flat when needed, with ``keep_label`` — left ``None``,
|
|
the usual case, it's derived from ``keep``'s own current 3D formát if
|
|
it has one, same as :meth:`add_movie_version`'s ``existing_label``);
|
|
its subtitles follow. ``keep``'s metadata wins — ``other`` only
|
|
contributes tags and a ČSFD link ``keep`` doesn't have yet — and
|
|
``other``'s own record entry is dropped so it doesn't linger as a
|
|
phantom row. An empty ``label`` for ``other`` is likewise fine — it
|
|
falls back to "Výchozí" (:meth:`add_movie_version`).
|
|
|
|
Raises:
|
|
ValueError: ``other`` is itself a multi-version movie, or both are
|
|
the same movie folder / file.
|
|
RuntimeError / FileExistsError: see :meth:`add_movie_version`.
|
|
"""
|
|
if other is keep or other.file_path == keep.file_path:
|
|
raise ValueError("Nelze sloučit film sám se sebou.")
|
|
if self.is_movie_group_member(other):
|
|
raise ValueError(
|
|
"Slučovaný film už má více verzí — slouč opačně, nebo verze nejdřív odeber."
|
|
)
|
|
if (
|
|
self.is_movie_group_member(keep)
|
|
and keep.file_path.parent == other.file_path.parent
|
|
):
|
|
raise ValueError("Oba soubory už jsou verzemi téhož filmu.")
|
|
|
|
for tag in other.tags:
|
|
if tag not in keep.tags:
|
|
keep.tags.append(tag)
|
|
if tag.full_path in other.auto_tag_paths:
|
|
keep.auto_tag_paths.add(tag.full_path)
|
|
if not keep.csfd_url and other.csfd_url:
|
|
keep.csfd_url = other.csfd_url
|
|
keep.save_metadata()
|
|
|
|
old_path = other.file_path
|
|
other.delete_metadata()
|
|
try:
|
|
new_version = self.add_movie_version(
|
|
keep, old_path, label, move=True, existing_label=keep_label
|
|
)
|
|
except Exception:
|
|
other.save_metadata() # put its media entry back — nothing moved
|
|
raise
|
|
subtitles.rename_subtitles_with(old_path, new_version.file_path)
|
|
if other in self.filelist:
|
|
self.filelist.remove(other)
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return new_version
|
|
|
|
def list_subtitles(self, file_obj: File) -> list:
|
|
"""External subtitle sidecars currently sitting next to a movie."""
|
|
return subtitles.find_subtitles(file_obj.file_path)
|
|
|
|
def attach_subtitle(
|
|
self,
|
|
file_obj: File,
|
|
source: Path,
|
|
*,
|
|
language: str | None = None,
|
|
flag: str | None = None,
|
|
move: bool = False,
|
|
) -> Path:
|
|
"""Copy (or move) a picked subtitle file next to ``file_obj``, correctly named.
|
|
|
|
Raises:
|
|
ValueError: ``source`` isn't a recognised subtitle format.
|
|
"""
|
|
return subtitles.attach_subtitle(
|
|
file_obj.file_path, source, language=language, flag=flag, move=move
|
|
)
|
|
|
|
def remove_subtitle(self, subtitle_path: Path) -> None:
|
|
"""Delete one attached subtitle file."""
|
|
Path(subtitle_path).unlink()
|
|
|
|
def refresh_tags_from_tmdb(
|
|
self,
|
|
files: list[File] | None = None,
|
|
on_progress: Callable[[int, int, File], None] | None = None,
|
|
should_cancel: Callable[[], bool] | None = None,
|
|
) -> dict:
|
|
"""(Re)derive each movie's tags from **TMDb** (by the tmdbid in its name).
|
|
|
|
Fetches genres / year / country / rating from TMDb per the tag schema and
|
|
regenerates the auto tags (`File.apply_nfo`, which is source-agnostic);
|
|
user-added tags are kept. Movies with no tmdbid in the filename are skipped.
|
|
|
|
Returns ``{ok, updated, without_id, errors}``.
|
|
"""
|
|
from . import tmdb
|
|
|
|
if not tmdb.api_available():
|
|
return {
|
|
"ok": False,
|
|
"error": "TMDB_API_KEY není nastaven (.env).",
|
|
"updated": 0,
|
|
"without_id": 0,
|
|
"errors": [],
|
|
}
|
|
targets = files if files is not None else self.filelist
|
|
schema = self.tag_schema
|
|
updated, without_id = 0, 0
|
|
errors: list[str] = []
|
|
total = len(targets)
|
|
for i, f in enumerate(targets, 1):
|
|
if should_cancel is not None and should_cancel():
|
|
break
|
|
tmdb_id = parse_tmdb_id(f.file_path.stem)
|
|
if tmdb_id is None:
|
|
without_id += 1
|
|
else:
|
|
try:
|
|
f.apply_nfo(tmdb.movie_details(tmdb_id), schema)
|
|
updated += 1
|
|
except tmdb.TmdbError as exc:
|
|
errors.append(f"{f.filename}: {exc}")
|
|
if on_progress is not None:
|
|
on_progress(i, total, f)
|
|
if updated and self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return {
|
|
"ok": True,
|
|
"updated": updated,
|
|
"without_id": without_id,
|
|
"errors": errors,
|
|
}
|
|
|
|
def resolve_via_csfd(
|
|
self,
|
|
*,
|
|
csfd_url: str | None = None,
|
|
query: str | None = None,
|
|
session: object | None = None,
|
|
) -> dict:
|
|
"""ČSFD → TMDb bridge for one movie.
|
|
|
|
With ``csfd_url`` set, reads that ČSFD film; otherwise searches ČSFD for
|
|
``query`` (a cleaned filename) and takes the top hit. The ČSFD original
|
|
title + year then drive a TMDb search — far more reliable than a raw
|
|
Czech-title search. Pass a shared ``requests.Session`` to reuse the
|
|
Anubis auth cookie across a batch.
|
|
|
|
Returns ``{"csfd_url", "film": CsfdFilm | None, "tmdb": TmdbResult | None}``.
|
|
Best-effort: a network / anti-bot / no-match failure is a ``None`` in the
|
|
result, not an exception.
|
|
"""
|
|
from . import csfd, tmdb
|
|
|
|
result: dict = {"csfd_url": csfd_url, "film": None, "tmdb": None}
|
|
try:
|
|
url = csfd_url
|
|
if not url and query:
|
|
url = csfd.find_csfd_url(query, session=session)
|
|
if not url:
|
|
return result
|
|
result["csfd_url"] = url
|
|
film = csfd.fetch_film(url, session=session)
|
|
result["film"] = film
|
|
title, year = film.tmdb_query
|
|
if title and tmdb.api_available():
|
|
hits = tmdb.search_movie(title, year=year, limit=1)
|
|
if not hits and film.czech_title and film.czech_title != title:
|
|
hits = tmdb.search_movie(film.czech_title, year=year, limit=1)
|
|
result["tmdb"] = hits[0] if hits else None
|
|
except csfd.CsfdError, tmdb.TmdbError, OSError:
|
|
pass
|
|
return result
|
|
|
|
def match_pool_to_tmdb(
|
|
self,
|
|
files: list[File] | None = None,
|
|
on_progress: Callable[[int, int, File], None] | None = None,
|
|
should_cancel: Callable[[], bool] | None = None,
|
|
) -> list:
|
|
"""Propose a TMDb id for every movie that doesn't have one yet.
|
|
|
|
Searches TMDb by each movie's title + year and classifies the result
|
|
(see :func:`migrate.classify`). A movie that carries a **ČSFD link**
|
|
(``File.csfd_url``) is first resolved through ČSFD so the search runs on
|
|
the *original* title + year — the pure Czech-title search picks the wrong
|
|
film for remakes/localized names. Movies whose filename already carries a
|
|
``[tmdbid-N]`` are skipped. Returns a list of ``migrate.MatchProposal``.
|
|
"""
|
|
from . import csfd, tmdb
|
|
from .migrate import MatchProposal, classify
|
|
|
|
targets = files if files is not None else self.filelist
|
|
proposals: list = []
|
|
total = len(targets)
|
|
csfd_session = None
|
|
for i, f in enumerate(targets, 1):
|
|
if should_cancel is not None and should_cancel():
|
|
break
|
|
if parse_tmdb_id(f.file_path.stem) is not None:
|
|
if on_progress is not None:
|
|
on_progress(i, total, f)
|
|
continue # already migrated
|
|
ctx = f.name_context()
|
|
title = (f.title or ctx.get("title") or f.file_path.stem).strip()
|
|
parsed = parse_pool_stem(title)
|
|
if parsed is not None:
|
|
title = parsed[0]
|
|
year = str(ctx.get("year") or "").strip() or None
|
|
|
|
note = ""
|
|
if f.csfd_url and csfd.dependencies_available():
|
|
if csfd_session is None and csfd.requests is not None:
|
|
csfd_session = csfd.requests.Session()
|
|
try:
|
|
film = csfd.fetch_film(f.csfd_url, session=csfd_session)
|
|
except csfd.CsfdError:
|
|
film = None
|
|
if film is not None and (film.original_title or film.czech_title):
|
|
title = film.original_title or film.czech_title
|
|
year = film.year or year
|
|
note = f"přes ČSFD: {title}" + (
|
|
f" ({film.year})" if film.year else ""
|
|
)
|
|
|
|
try:
|
|
candidates = tmdb.search_movie(title, year=year, limit=5)
|
|
status, chosen = classify(title, year, candidates)
|
|
proposals.append(
|
|
MatchProposal(
|
|
f,
|
|
f.filename,
|
|
title,
|
|
year,
|
|
status,
|
|
chosen,
|
|
candidates,
|
|
note=note,
|
|
)
|
|
)
|
|
except tmdb.TmdbError as exc:
|
|
proposals.append(
|
|
MatchProposal(
|
|
f, f.filename, title, year, "error", None, [], str(exc), note
|
|
)
|
|
)
|
|
if on_progress is not None:
|
|
on_progress(i, total, f)
|
|
return proposals
|
|
|
|
def assign_tmdb_id(
|
|
self, file_obj: File, tmdb_id: int | str, year: int | str | None = None
|
|
) -> File:
|
|
"""Attach a TMDb id to a movie and rename it to the canonical convention.
|
|
|
|
``year`` (typically from the TMDb match) fills a missing local year so the
|
|
file becomes ``Title (rok) [tmdbid].ext``. Title/library/tags/date are kept.
|
|
"""
|
|
ctx = file_obj.name_context()
|
|
title = (file_obj.title or ctx.get("title") or file_obj.file_path.stem).strip()
|
|
parsed = parse_pool_stem(title)
|
|
if parsed is not None:
|
|
title = parsed[0]
|
|
year_s = (
|
|
str(year).strip() if year not in (None, "") else str(ctx.get("year") or "")
|
|
)
|
|
return self.apply_movie_edits(
|
|
file_obj, title=title, year=year_s or None, tmdb_id=tmdb_id
|
|
)
|
|
|
|
def refresh_tags_from_nfo(self, files: list[File] | None = None) -> dict:
|
|
"""(Re)derive each movie's auto tags from its NFO (written by Jellyfin).
|
|
|
|
Reads the ``.nfo`` next to each movie and regenerates the genre / year /
|
|
country / rating tags per the tag schema (`File.apply_nfo`); user-added
|
|
tags are preserved. Movies without an NFO yet are left untouched.
|
|
|
|
Returns ``{updated, without_nfo}``.
|
|
"""
|
|
from .nfo import read_nfo
|
|
|
|
targets = files if files is not None else self.filelist
|
|
schema = self.tag_schema
|
|
updated, without_nfo = 0, 0
|
|
for f in targets:
|
|
nfo = read_nfo(f.file_path)
|
|
if nfo is None:
|
|
without_nfo += 1
|
|
continue
|
|
f.apply_nfo(nfo, schema)
|
|
updated += 1
|
|
if updated and self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
return {"updated": updated, "without_nfo": without_nfo}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Series (Jellyfin TV layout: Series/Season NN/Series SxxEyy.ext)
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def series_libraries(self) -> list[str]:
|
|
"""Pool folders that can hold series — kind ``series`` or ``mixed``."""
|
|
return self.libraries_of_kind("series", "mixed")
|
|
|
|
def load_series(self) -> list:
|
|
"""Scan every series library into a list of ``series.Series``.
|
|
|
|
A ``mixed`` library also skips a canonical-shaped subfolder that
|
|
doesn't look like a series (see ``series.scan_series``'s
|
|
``skip_movie_folders``) — that's a movie's version folder instead,
|
|
read by :meth:`load_pool_movies`. A plain ``series``-kind library
|
|
never holds movies, so it keeps reading every subfolder as a series
|
|
regardless of shape (a raw, not-yet-renamed import may have loose
|
|
files sitting at the series root, with no season folder yet).
|
|
"""
|
|
from .series import scan_series
|
|
|
|
pool = self.pool_dir
|
|
if not pool:
|
|
return []
|
|
result: list = []
|
|
for lib in self.libraries:
|
|
if lib["kind"] not in ("series", "mixed"):
|
|
continue
|
|
result.extend(
|
|
scan_series(
|
|
pool / lib["name"],
|
|
lib["name"],
|
|
skip_movie_folders=lib["kind"] == "mixed",
|
|
)
|
|
)
|
|
return result
|
|
|
|
def import_episodes(
|
|
self,
|
|
sources: list[Path],
|
|
title: str,
|
|
year: int | str | None = None,
|
|
tmdb_id: int | str | None = None,
|
|
library: str | None = None,
|
|
move: bool = False,
|
|
on_conflict: str = "suffix",
|
|
codes: Mapping[Path, tuple[int, int] | tuple[int, int, int | None]]
|
|
| None = None,
|
|
) -> dict:
|
|
"""Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``.
|
|
|
|
Each source's season/episode/part comes from ``codes`` (an explicit
|
|
``{path: (season, episode)}`` or ``{path: (season, episode, part)}``
|
|
override set at import time) when present, otherwise it is parsed from
|
|
the filename. It is then copied (or moved) in as ``Series SxxEyy.ext``
|
|
(``.ptN`` appended when it's just one part of that episode). Files with
|
|
neither an override nor a recognisable ``SxxEyy`` are skipped and
|
|
reported.
|
|
|
|
Returns ``{imported, skipped_no_code, errors}``.
|
|
"""
|
|
from .series import (
|
|
episode_filename,
|
|
parse_episode,
|
|
parse_episode_part,
|
|
season_folder_name,
|
|
series_folder_stem,
|
|
)
|
|
|
|
codes = codes or {}
|
|
pool = self.pool_dir
|
|
if pool is None:
|
|
raise RuntimeError("Pool není nastaven.")
|
|
lib = (library or "").strip() or (
|
|
self.series_libraries[0] if self.series_libraries else POOL_SERIES
|
|
)
|
|
stem = series_folder_stem(title, year, tmdb_id)
|
|
|
|
imported = 0
|
|
skipped_no_code: list[str] = []
|
|
errors: list[str] = []
|
|
for src in sources:
|
|
src = Path(src)
|
|
override = codes.get(src)
|
|
if override is not None:
|
|
season, ep = override[0], override[1]
|
|
part = override[2] if len(override) > 2 else None
|
|
else:
|
|
code = parse_episode(src.name)
|
|
if code is None:
|
|
skipped_no_code.append(src.name)
|
|
continue
|
|
season, ep = code
|
|
part = parse_episode_part(src.name)
|
|
dest_dir = pool / lib / stem / season_folder_name(season)
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
target = dest_dir / episode_filename(
|
|
season, ep, title, src.suffix, part=part
|
|
)
|
|
if target.exists():
|
|
if on_conflict == "skip":
|
|
continue
|
|
if on_conflict == "replace":
|
|
target.unlink()
|
|
else: # suffix
|
|
base = episode_filename(season, ep, title, "", part=part)
|
|
counter = 1
|
|
while target.exists():
|
|
target = dest_dir / f"{base}_{counter}{src.suffix}"
|
|
counter += 1
|
|
try:
|
|
if move:
|
|
shutil.move(str(src), str(target))
|
|
else:
|
|
shutil.copy2(src, target)
|
|
imported += 1
|
|
except OSError as exc:
|
|
errors.append(f"{src.name}: {exc}")
|
|
return {
|
|
"imported": imported,
|
|
"skipped_no_code": skipped_no_code,
|
|
"errors": errors,
|
|
}
|
|
|
|
def rename_episodes_to_canonical(self, series) -> dict:
|
|
"""Rename a series' episodes to ``SxxEyy - {series title}.ext`` in its season.
|
|
|
|
``series`` is a ``series.Series``. Episodes with no recognisable code are
|
|
skipped; a target name already taken is reported as a conflict.
|
|
|
|
Returns ``{renamed, skipped, conflicts}``.
|
|
"""
|
|
from .series import episode_filename, season_folder_name
|
|
|
|
renamed: list[tuple[str, str]] = []
|
|
skipped: list[str] = []
|
|
conflicts: list[tuple[str, str]] = []
|
|
for ep in series.episodes:
|
|
if ep.season is None or ep.episode is None:
|
|
skipped.append(ep.path.name)
|
|
continue
|
|
dest_dir = series.folder / season_folder_name(ep.season)
|
|
new_path = dest_dir / episode_filename(
|
|
ep.season, ep.episode, series.title, ep.path.suffix, part=ep.part
|
|
)
|
|
if new_path == ep.path:
|
|
continue
|
|
if new_path.exists():
|
|
conflicts.append((ep.path.name, f"„{new_path.name}“ už existuje"))
|
|
continue
|
|
try:
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
old_path = ep.path
|
|
old_path.rename(new_path)
|
|
subtitles.rename_subtitles_with(old_path, new_path)
|
|
ep.path = new_path
|
|
renamed.append((old_path.name, new_path.name))
|
|
except OSError as exc:
|
|
conflicts.append((ep.path.name, str(exc)))
|
|
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
|
|
|
|
def rename_all_series_to_canonical(
|
|
self,
|
|
on_progress: Callable[[int, int, str], None] | None = None,
|
|
should_cancel: Callable[[], bool] | None = None,
|
|
) -> dict:
|
|
"""Canonicalise every series' episode filenames across the pool.
|
|
|
|
Iterates all series (``series`` + ``mixed`` libraries); per series it
|
|
renames each episode to ``SxxEyy - {series title}.ext`` in the right
|
|
``Season NN`` / ``Specials`` folder (:meth:`rename_episodes_to_canonical`)
|
|
and then removes any season folder left empty by the moves. Episodes with
|
|
no recognisable ``SxxEyy`` are left in place and listed.
|
|
|
|
Returns ``{series, renamed:[(series, old, new)],
|
|
skipped_no_code:[(series, name)], conflicts:[(series, name, msg)],
|
|
empty_dirs_removed:[str], cancelled}``.
|
|
"""
|
|
from .series import is_season_folder
|
|
|
|
pool = self.pool_dir
|
|
if pool is None:
|
|
raise RuntimeError("Pool není nastaven.")
|
|
all_series = self.load_series()
|
|
total = len(all_series)
|
|
renamed: list[tuple[str, str, str]] = []
|
|
skipped: list[tuple[str, str]] = []
|
|
conflicts: list[tuple[str, str, str]] = []
|
|
empty_removed: list[str] = []
|
|
cancelled = False
|
|
for i, ser in enumerate(all_series, 1):
|
|
if should_cancel is not None and should_cancel():
|
|
cancelled = True
|
|
break
|
|
if on_progress is not None:
|
|
on_progress(i, total, ser.title)
|
|
res = self.rename_episodes_to_canonical(ser)
|
|
renamed += [(ser.title, old, new) for old, new in res["renamed"]]
|
|
skipped += [(ser.title, name) for name in res["skipped"]]
|
|
conflicts += [(ser.title, name, msg) for name, msg in res["conflicts"]]
|
|
for sub in sorted(p for p in ser.folder.iterdir() if p.is_dir()):
|
|
if is_season_folder(sub.name) and not any(sub.iterdir()):
|
|
try:
|
|
sub.rmdir()
|
|
empty_removed.append(str(sub.relative_to(pool)))
|
|
except OSError:
|
|
pass
|
|
return {
|
|
"series": total,
|
|
"renamed": renamed,
|
|
"skipped_no_code": skipped,
|
|
"conflicts": conflicts,
|
|
"empty_dirs_removed": empty_removed,
|
|
"cancelled": cancelled,
|
|
}
|
|
|
|
def apply_series_edits(
|
|
self,
|
|
series,
|
|
*,
|
|
title: str,
|
|
year: int | str | None = None,
|
|
tmdb_id: int | str | None = None,
|
|
library: str | None = None,
|
|
) -> dict:
|
|
"""Rename/move a series folder and re-title its episodes to match.
|
|
|
|
Renames the series folder to ``Title (rok) [tmdbid]`` (moving it into
|
|
``library`` if changed), then re-derives the series and renames every
|
|
episode to ``NewTitle SxxEyy.ext`` in the right Season folder.
|
|
|
|
Returns ``{folder, episodes}`` (``episodes`` = rename_episodes result).
|
|
|
|
Raises:
|
|
FileExistsError: the target series folder already exists.
|
|
"""
|
|
from .series import scan_series, series_folder_stem
|
|
|
|
pool = self.pool_dir
|
|
if pool is None:
|
|
raise RuntimeError("Pool není nastaven.")
|
|
title = (title or "").strip() or series.title
|
|
new_stem = series_folder_stem(title, year or None, tmdb_id or None)
|
|
lib = (library or "").strip() or series.library
|
|
old_folder = series.folder
|
|
new_folder = pool / lib / new_stem
|
|
if new_folder != old_folder:
|
|
if new_folder.exists():
|
|
raise FileExistsError(f"Složka „{new_stem}“ už existuje.")
|
|
new_folder.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.move(str(old_folder), str(new_folder))
|
|
|
|
target = next(
|
|
(s for s in scan_series(new_folder.parent, lib) if s.folder == new_folder),
|
|
None,
|
|
)
|
|
result = (
|
|
self.rename_episodes_to_canonical(target)
|
|
if target
|
|
else {"renamed": [], "skipped": [], "conflicts": []}
|
|
)
|
|
return {"folder": new_folder, "episodes": result}
|
|
|
|
def remove_series(self, series) -> None:
|
|
"""Delete a whole series folder from the pool (every season and episode).
|
|
|
|
Series carry no index metadata (they are scanned from disk), so removing
|
|
one is just deleting its folder. Refuses a folder that is not inside the
|
|
pool as a safety check.
|
|
|
|
Raises:
|
|
ValueError: the series folder is outside the pool.
|
|
"""
|
|
pool = self.pool_dir
|
|
if pool is None:
|
|
raise RuntimeError("Pool není nastaven.")
|
|
folder = Path(series.folder)
|
|
try:
|
|
folder.resolve().relative_to(pool.resolve())
|
|
except ValueError:
|
|
raise ValueError(f"Složka „{folder}“ není v poolu.")
|
|
if folder.is_dir():
|
|
shutil.rmtree(folder)
|
|
|
|
def remove_episodes(self, episodes: list) -> None:
|
|
"""Delete specific episode files (+ their subtitles) from the pool.
|
|
|
|
``episodes`` is a list of ``series.Episode`` — series carry no index
|
|
metadata (they are scanned live from disk), so this is just deleting
|
|
files. A ``Season NN``/``Specials`` folder left completely empty by the
|
|
removal is cleaned up too; one that still holds something else (a
|
|
stray sidecar, another episode) is left alone.
|
|
"""
|
|
from .series import is_season_folder
|
|
|
|
touched: set[Path] = set()
|
|
for ep in episodes:
|
|
path = Path(ep.path)
|
|
for sub in subtitles.find_subtitles(path):
|
|
sub.path.unlink(missing_ok=True)
|
|
path.unlink(missing_ok=True)
|
|
touched.add(path.parent)
|
|
for folder in touched:
|
|
if (
|
|
folder.is_dir()
|
|
and is_season_folder(folder.name)
|
|
and not any(folder.iterdir())
|
|
):
|
|
try:
|
|
folder.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
def remove_season(self, series, season: int) -> None:
|
|
"""Delete every episode of one season of ``series`` (+ the season folder).
|
|
|
|
``season`` is a season number as reported by ``series.seasons()``
|
|
(``-1`` = episodes with no recognisable season — there is no single
|
|
folder for those, just the files themselves get removed).
|
|
|
|
Raises:
|
|
ValueError: ``series`` has no episodes in that season.
|
|
"""
|
|
eps = series.seasons().get(season, [])
|
|
if not eps:
|
|
raise ValueError("Tahle sezóna už žádné epizody nemá.")
|
|
self.remove_episodes(eps)
|
|
|
|
def assign_tag_to_file_objects(self, files_objs: list[File], tag):
|
|
"""Přiřadí tag (Tag nebo 'category/name' string) ke každému souboru v seznamu."""
|
|
for f in files_objs:
|
|
if isinstance(tag, str):
|
|
if "/" in tag:
|
|
category, name = tag.split("/", 1)
|
|
tag_obj = self.tagmanager.add_tag(category, name)
|
|
else:
|
|
tag_obj = self.tagmanager.add_tag("default", tag)
|
|
else:
|
|
tag_obj = tag
|
|
if tag_obj not in f.tags:
|
|
f.tags.append(tag_obj)
|
|
f.save_metadata()
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
|
|
def remove_tag_from_file_objects(self, files_objs: list[File], tag):
|
|
"""Odebere tag (Tag nebo 'category/name') ze všech uvedených souborů."""
|
|
for f in files_objs:
|
|
if isinstance(tag, str):
|
|
if "/" in tag:
|
|
category, name = tag.split("/", 1)
|
|
from .tag import Tag as TagClass
|
|
|
|
tag_obj = TagClass(category, name)
|
|
else:
|
|
from .tag import Tag as TagClass
|
|
|
|
tag_obj = TagClass("default", tag)
|
|
else:
|
|
tag_obj = tag
|
|
if tag_obj in f.tags:
|
|
f.tags.remove(tag_obj)
|
|
f.save_metadata()
|
|
if self.on_files_changed:
|
|
self.on_files_changed(self.filelist)
|
|
|
|
def filter_files_by_tags(self, tags: Iterable):
|
|
"""
|
|
Vrátí jen soubory, které obsahují všechny zadané tagy.
|
|
'tags' může být iterace Tag objektů nebo stringů 'category/name'.
|
|
"""
|
|
tags_list = list(tags) if tags is not None else []
|
|
if not tags_list:
|
|
return self.filelist
|
|
|
|
target_full_paths = set()
|
|
from .tag import Tag as TagClass
|
|
|
|
for t in tags_list:
|
|
if isinstance(t, TagClass):
|
|
target_full_paths.add(t.full_path)
|
|
elif isinstance(t, str):
|
|
target_full_paths.add(t)
|
|
else:
|
|
continue
|
|
|
|
filtered = []
|
|
for f in self.filelist:
|
|
file_tags = {t.full_path for t in f.tags}
|
|
if all(tag in file_tags for tag in target_full_paths):
|
|
filtered.append(f)
|
|
return filtered
|