Files
Curator/src/core/file_manager.py
T

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