Add movie-keyed pool index with multi-version movies, ČSFD title bridge, subtitles and miniDLNA library scoping
This commit is contained in:
+551
-39
@@ -7,7 +7,10 @@ from .tag_manager import TagManager
|
||||
from .pool_index import PoolIndex
|
||||
from .utils import list_files
|
||||
from .integrity import check_video_integrity, ffmpeg_available, IntegrityResult
|
||||
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem, parse_tmdb_id
|
||||
from .naming import (
|
||||
canonical_pool_stem, is_canonical_pool_stem, is_valid_year, parse_3d_format,
|
||||
parse_pool_stem, parse_tmdb_id)
|
||||
from . import subtitles
|
||||
from typing import Callable, Iterable
|
||||
from src.core.config import (
|
||||
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
|
||||
@@ -168,6 +171,118 @@ class FileManager:
|
||||
"""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."""
|
||||
@@ -334,7 +449,7 @@ class FileManager:
|
||||
|
||||
Returns a dict with:
|
||||
ok — False if the pool isn't set (with 'error')
|
||||
missing — index keys whose file no longer exists on disk
|
||||
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
|
||||
"""
|
||||
@@ -344,7 +459,14 @@ class FileManager:
|
||||
"missing": [], "untracked": []}
|
||||
|
||||
index = self.index or PoolIndex(pool)
|
||||
index_keys = set(index.records.keys())
|
||||
# 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:
|
||||
@@ -518,6 +640,7 @@ class FileManager:
|
||||
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,
|
||||
) -> File | None:
|
||||
"""Bring a video file into a movie library and index its metadata.
|
||||
|
||||
@@ -527,6 +650,8 @@ class FileManager:
|
||||
``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.
|
||||
|
||||
By default the original is **copied** (non-destructive). With ``move=True``
|
||||
the source file is moved into the pool instead, leaving nothing behind.
|
||||
@@ -555,7 +680,7 @@ class FileManager:
|
||||
source = Path(source)
|
||||
safe_title = title.strip() or source.stem
|
||||
has_year = year is not None and str(year).strip() != ""
|
||||
stem = (canonical_pool_stem(safe_title, year, tmdb_id) if has_year
|
||||
stem = (canonical_pool_stem(safe_title, year, tmdb_id, format_3d) if has_year
|
||||
else safe_title)
|
||||
target = movies / f"{stem}{source.suffix}"
|
||||
|
||||
@@ -600,7 +725,9 @@ class FileManager:
|
||||
extension is preserved; ``new_title`` is the bare name without it.
|
||||
|
||||
Raises:
|
||||
ValueError: empty name or a name containing a path separator.
|
||||
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()
|
||||
@@ -608,6 +735,10 @@ class FileManager:
|
||||
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}")
|
||||
@@ -618,6 +749,7 @@ class FileManager:
|
||||
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()
|
||||
@@ -626,13 +758,15 @@ class FileManager:
|
||||
self.on_files_changed(self.filelist)
|
||||
return file_obj
|
||||
|
||||
def _canonical_name_for(self, file_obj: File) -> str | None:
|
||||
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]`` on the current filename is preserved. Returns
|
||||
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()
|
||||
@@ -644,13 +778,14 @@ class FileManager:
|
||||
title = parsed[0]
|
||||
year = year or str(parsed[1])
|
||||
|
||||
# Keep the TMDb id already in the pool filename, if any.
|
||||
# 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)
|
||||
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.
|
||||
@@ -658,15 +793,20 @@ class FileManager:
|
||||
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.
|
||||
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, so no canonical name can be built.
|
||||
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
|
||||
@@ -680,6 +820,7 @@ class FileManager:
|
||||
|
||||
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]
|
||||
@@ -740,7 +881,9 @@ class FileManager:
|
||||
|
||||
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.
|
||||
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
|
||||
@@ -749,6 +892,10 @@ class FileManager:
|
||||
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
|
||||
@@ -760,6 +907,7 @@ class FileManager:
|
||||
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()
|
||||
|
||||
@@ -772,13 +920,19 @@ class FileManager:
|
||||
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].ext`` (year 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.
|
||||
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.
|
||||
@@ -788,18 +942,34 @@ class FileManager:
|
||||
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 ""
|
||||
stem = (canonical_pool_stem(title, year_s, tmdb_id)
|
||||
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
|
||||
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))
|
||||
file_obj.relocate(new_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:
|
||||
@@ -826,6 +996,218 @@ class FileManager:
|
||||
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.
|
||||
|
||||
Only recognised for ``movie``-kind libraries in this first cut — a
|
||||
``mixed`` library's subfolders are always treated as series.
|
||||
"""
|
||||
pool = self.pool_dir
|
||||
lib = self.library_of(file_obj)
|
||||
if pool is None or lib is None or lib not in self.libraries_of_kind("movie"):
|
||||
return False
|
||||
parent = file_obj.file_path.parent
|
||||
return parent != pool / lib and is_canonical_pool_stem(parent.name)
|
||||
|
||||
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 = "Výchozí",
|
||||
) -> 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).
|
||||
|
||||
Raises:
|
||||
RuntimeError: no pool, or ``file_obj`` has no valid title+year yet.
|
||||
ValueError: ``label`` is empty.
|
||||
FileExistsError: a version of that name already exists.
|
||||
"""
|
||||
label = (label or "").strip()
|
||||
if not label:
|
||||
raise ValueError("Popisek verze nesmí být prázdný.")
|
||||
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
|
||||
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í.")
|
||||
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 / f"{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_metadata()
|
||||
|
||||
target = folder / f"{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 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 — 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).
|
||||
"""
|
||||
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)
|
||||
new_path = folder.parent / f"{folder.name}{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_metadata()
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
|
||||
if self.on_files_changed:
|
||||
self.on_files_changed(self.filelist)
|
||||
|
||||
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,
|
||||
@@ -867,6 +1249,43 @@ class FileManager:
|
||||
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,
|
||||
@@ -875,14 +1294,18 @@ class FileManager:
|
||||
"""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`). Movies whose filename already carries a
|
||||
(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 tmdb
|
||||
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
|
||||
@@ -896,14 +1319,28 @@ class FileManager:
|
||||
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))
|
||||
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)))
|
||||
f, f.filename, title, year, "error", None, [], str(exc), note))
|
||||
if on_progress is not None:
|
||||
on_progress(i, total, f)
|
||||
return proposals
|
||||
@@ -973,17 +1410,21 @@ class FileManager:
|
||||
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: dict[Path, tuple[int, int]] | None = None,
|
||||
) -> dict:
|
||||
"""Import episode files into ``<library>/Series (rok) [tmdbid]/Season NN/``.
|
||||
|
||||
Each source's season/episode is parsed from its filename and it is copied
|
||||
(or moved) in as ``Series SxxEyy.ext``. Files with no recognisable
|
||||
``SxxEyy`` are skipped and reported.
|
||||
Each source's season/episode comes from ``codes`` (an explicit
|
||||
``{path: (season, episode)}`` 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``. Files with neither an override nor a
|
||||
recognisable ``SxxEyy`` are skipped and reported.
|
||||
|
||||
Returns ``{imported, skipped_no_code, errors}``.
|
||||
"""
|
||||
from .series import (
|
||||
parse_episode, series_folder_stem, season_folder_name, episode_filename)
|
||||
codes = codes or {}
|
||||
pool = self.pool_dir
|
||||
if pool is None:
|
||||
raise RuntimeError("Pool není nastaven.")
|
||||
@@ -996,21 +1437,21 @@ class FileManager:
|
||||
errors: list[str] = []
|
||||
for src in sources:
|
||||
src = Path(src)
|
||||
code = parse_episode(src.name)
|
||||
code = codes.get(src) or parse_episode(src.name)
|
||||
if code is None:
|
||||
skipped_no_code.append(src.name)
|
||||
continue
|
||||
season, ep = code
|
||||
dest_dir = pool / lib / stem / season_folder_name(season)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = dest_dir / episode_filename(season, ep, src.stem, src.suffix)
|
||||
target = dest_dir / episode_filename(season, ep, title, src.suffix)
|
||||
if target.exists():
|
||||
if on_conflict == "skip":
|
||||
continue
|
||||
if on_conflict == "replace":
|
||||
target.unlink()
|
||||
else: # suffix
|
||||
base = episode_filename(season, ep, src.stem, "")
|
||||
base = episode_filename(season, ep, title, "")
|
||||
counter = 1
|
||||
while target.exists():
|
||||
target = dest_dir / f"{base}_{counter}{src.suffix}"
|
||||
@@ -1027,7 +1468,7 @@ class FileManager:
|
||||
"errors": errors}
|
||||
|
||||
def rename_episodes_to_canonical(self, series) -> dict:
|
||||
"""Rename a series' episodes to ``Series SxxEyy.ext`` in the right season.
|
||||
"""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.
|
||||
@@ -1044,7 +1485,7 @@ class FileManager:
|
||||
continue
|
||||
dest_dir = series.folder / season_folder_name(ep.season)
|
||||
new_path = dest_dir / episode_filename(
|
||||
ep.season, ep.episode, ep.path.stem, ep.path.suffix)
|
||||
ep.season, ep.episode, series.title, ep.path.suffix)
|
||||
if new_path == ep.path:
|
||||
continue
|
||||
if new_path.exists():
|
||||
@@ -1052,14 +1493,64 @@ class FileManager:
|
||||
continue
|
||||
try:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
old_name = ep.path.name
|
||||
ep.path.rename(new_path)
|
||||
old_path = ep.path
|
||||
old_path.rename(new_path)
|
||||
subtitles.rename_subtitles_with(old_path, new_path)
|
||||
ep.path = new_path
|
||||
renamed.append((old_name, new_path.name))
|
||||
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,
|
||||
@@ -1096,6 +1587,27 @@ class FileManager:
|
||||
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 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:
|
||||
|
||||
Reference in New Issue
Block a user