Rework Curator into a Jellyfin-first library manager

This commit is contained in:
2026-08-31 18:34:03 +02:00
parent 1c78ca9013
commit 6a2baf243f
25 changed files with 2420 additions and 1502 deletions
+308 -40
View File
@@ -7,7 +7,7 @@ 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
from .naming import canonical_pool_stem, is_valid_year, parse_pool_stem, parse_tmdb_id
from typing import Callable, Iterable
from src.core.config import (
load_global_config, save_global_config, DEFAULT_TAG_SCHEMA
@@ -21,6 +21,9 @@ POOL_SERIES = "Seriály"
_LIBRARY_SETTINGS_DEFAULTS = {
"tag_schema": DEFAULT_TAG_SCHEMA,
"copyasis_folders": [POOL_SERIES],
# Top-level pool folders that hold movies — each is a Jellyfin library
# (physical partition). "Filmy" is the default; users add Kids/Dokumenty/…
"movie_libraries": [POOL_MOVIES],
}
# Curator metadata files that must never be treated as content. The legacy
@@ -28,6 +31,13 @@ _LIBRARY_SETTINGS_DEFAULTS = {
# 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",
)
class FileManager:
def __init__(self, tagmanager: TagManager):
@@ -117,6 +127,27 @@ class FileManager:
cleaned = [n.strip() for n in names if n.strip()]
self._set_setting("copyasis_folders", cleaned)
@property
def movie_libraries(self) -> list[str]:
"""Top-level pool folders holding movies (each a Jellyfin library)."""
libs = self._get_setting("movie_libraries")
return libs if libs else [POOL_MOVIES]
def set_movie_libraries(self, names: list[str]) -> None:
"""Set the movie-library list (never empty — falls back to Filmy)."""
cleaned = [n.strip() for n in names if n.strip()]
self._set_setting("movie_libraries", cleaned or [POOL_MOVIES])
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)."""
@@ -164,12 +195,13 @@ class FileManager:
return Path(value) if value else None
def set_pool_dir(self, pool_dir: Path) -> None:
"""Set the managed pool root and create its top-level folders."""
"""Set the managed pool root and create every library's top-level folder."""
pool_dir = Path(pool_dir)
(pool_dir / POOL_MOVIES).mkdir(parents=True, exist_ok=True)
(pool_dir / POOL_SERIES).mkdir(parents=True, exist_ok=True)
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 name in self.movie_libraries + self.copyasis_folders:
(pool_dir / 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)."""
@@ -177,22 +209,33 @@ class FileManager:
save_global_config(self.global_config)
def load_pool_movies(self) -> None:
"""Reload the movie list from pool/Filmy using the unified pool index."""
"""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.
"""
self.filelist = []
movies = self.movies_dir
pool = self.pool_dir
if not (movies and movies.is_dir() and pool):
if not pool:
return
self._open_index(pool)
for each in list_files(movies):
if each.name.endswith(METADATA_SUFFIXES):
for library in self.movie_libraries:
lib_dir = pool / library
if not lib_dir.is_dir():
continue
file_obj = File(each, self.tagmanager, index=self.index)
self.filelist.append(file_obj)
for each in list_files(lib_dir):
if each.name.endswith(METADATA_SUFFIXES):
continue
if each.suffix.lower() in SIDECAR_SUFFIXES:
continue
file_obj = File(each, self.tagmanager, index=self.index)
self.filelist.append(file_obj)
def check_data_consistency(self) -> dict:
"""Compare the pool index against the actual files in pool/Filmy.
"""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).
@@ -204,8 +247,7 @@ class FileManager:
index_count / disk_count — totals for the summary
"""
pool = self.pool_dir
movies = self.movies_dir
if not (pool and movies and movies.is_dir()):
if not (pool and pool.is_dir()):
return {"ok": False, "error": "Pool není nastaven.",
"missing": [], "untracked": []}
@@ -213,10 +255,16 @@ class FileManager:
index_keys = set(index.records.keys())
disk_keys = set()
for f in list_files(movies):
if f.name.endswith(METADATA_SUFFIXES):
for library in self.movie_libraries:
lib_dir = pool / library
if not lib_dir.is_dir():
continue
disk_keys.add(f.relative_to(pool).as_posix())
for f in list_files(lib_dir):
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)
@@ -228,6 +276,51 @@ class FileManager:
"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],
@@ -279,7 +372,7 @@ class FileManager:
"""
files = self.filelist
total_size = 0
with_csfd = 0
with_nfo = 0
untagged = 0
ratings: list[int] = []
categories: dict[str, Counter] = {}
@@ -289,21 +382,21 @@ class FileManager:
total_size += f.file_path.stat().st_size
except OSError:
pass
if f.csfd_link:
with_csfd += 1
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.csfd_cache or {}).get("rating")
rating = (f.meta or {}).get("rating")
if isinstance(rating, (int, float)):
ratings.append(rating)
return {
"count": len(files),
"total_size": total_size,
"with_csfd": with_csfd,
"without_csfd": len(files) - with_csfd,
"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": {
@@ -325,16 +418,19 @@ class FileManager:
self.filelist.remove(file_obj)
def import_movie(
self, source: Path, title: str, csfd_link: str | None = None,
self, source: Path, title: str,
move: bool = False, on_conflict: str = "suffix",
year: int | str | None = None,
year: int | str | None = None, tmdb_id: int | str | None = None,
library: str | None = None,
) -> File | None:
"""Bring a video file into pool/Filmy and index its metadata.
"""Bring a video file into a movie library and index its metadata.
The pooled filename follows the naming convention ``Title (YYYY).ext``
when a ``year`` is given (validated to four digits); without a year it
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).ext``.
library built through the app is uniformly ``Title (YYYY) [tmdbid-N].ext``.
By default the original is **copied** (non-destructive). With ``move=True``
the source file is moved into the pool instead, leaving nothing behind.
@@ -350,10 +446,11 @@ class FileManager:
Raises:
ValueError: a ``year`` was given but is not a four-digit number.
"""
movies = self.movies_dir
pool = self.pool_dir
if movies is None or pool is None:
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:
@@ -362,7 +459,8 @@ 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) if has_year else safe_title
stem = (canonical_pool_stem(safe_title, year, tmdb_id) if has_year
else safe_title)
target = movies / f"{stem}{source.suffix}"
existing = self.pooled_with_stem(stem)
@@ -389,7 +487,6 @@ class FileManager:
file_obj = File(target, self.tagmanager, index=self.index)
file_obj.title = safe_title
file_obj.csfd_link = csfd_link or None
file_obj.added = datetime.now().isoformat(timespec="seconds")
file_obj.save_metadata()
@@ -433,12 +530,13 @@ class FileManager:
return file_obj
def _canonical_name_for(self, file_obj: File) -> str | None:
"""The ``Title (YYYY)`` stem this movie should have, or ``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``: ČSFD cache → ``Rok`` tag → the current filename). A
``(YYYY)`` that leaked into the title is stripped so it isn't doubled.
Returns ``None`` when no four-digit year is known.
(``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
``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()
@@ -449,16 +547,19 @@ class FileManager:
title = parsed[0]
year = year or str(parsed[1])
# Keep the TMDb id already in the pool filename, if any.
tmdb_id = parse_tmdb_id(file_obj.file_path.stem)
title = title.replace("/", "-").replace("\\", "-").strip()
if not (title and is_valid_year(year)):
return None
return canonical_pool_stem(title, year)
return canonical_pool_stem(title, year, tmdb_id)
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 ČSFD metadata (falling back to a ``Rok``
tag or the existing filename). ``file_obj.title`` keeps the clean title
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.
@@ -537,6 +638,173 @@ class FileManager:
"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.
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.movie_libraries:
raise ValueError(f"Neznámá knihovna „{new_library}“.")
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))
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 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 hold series (the copy-as-is mirrors, e.g. Seriály)."""
return self.copyasis_folders
def load_series(self) -> list:
"""Scan every series library into a list of ``series.Series``."""
from .series import scan_series
pool = self.pool_dir
if not pool:
return []
result: list = []
for library in self.series_libraries:
result.extend(scan_series(pool / library, library))
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",
) -> 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.
Returns ``{imported, skipped_no_code, errors}``.
"""
from .series import (
parse_episode, series_folder_stem, season_folder_name, episode_filename)
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)
code = 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(title, season, ep, src.suffix)
if target.exists():
if on_conflict == "skip":
continue
if on_conflict == "replace":
target.unlink()
else: # suffix
base = episode_filename(title, season, ep, "")
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 ``Series SxxEyy.ext`` in the right 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 season_folder_name, episode_filename
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(
series.title, ep.season, ep.episode, ep.path.suffix)
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_name = ep.path.name
ep.path.rename(new_path)
ep.path = new_path
renamed.append((old_name, new_path.name))
except OSError as exc:
conflicts.append((ep.path.name, str(exc)))
return {"renamed": renamed, "skipped": skipped, "conflicts": conflicts}
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: