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
+43 -79
View File
@@ -1,10 +1,6 @@
from pathlib import Path
from .tag import Tag
# Bump this when the csfd_cache schema changes to force re-fetch on next open.
# v2: country (str) → countries (list[str]) for co-productions.
CSFD_CACHE_VERSION = 2
class File:
def __init__(self, file_path: Path, tagmanager=None, index=None) -> None:
@@ -22,17 +18,17 @@ class File:
self.date: str | None = None
# movie-library fields
self.title: str | None = None
self.csfd_link: str | None = None
# ISO timestamp of when the movie was added to the pool (set on import);
# None for older/unknown → falls back to the file's ctime.
self.added: str | None = None
# Cached CSFD data — avoids re-fetching on every open
self.csfd_cache: dict | None = None
# full_paths of tags that came from ČSFD (vs. user-added); only these are
# regenerated when the ČSFD link changes — user tags are preserved.
self.csfd_tag_paths: set[str] = set()
# Cached metadata used for folder grouping (year, rating) — filled from
# the movie's NFO (which Jellyfin writes).
self.meta: dict | None = None
# full_paths of tags derived automatically from the NFO (vs. user-added);
# only these are regenerated on refresh — user tags are preserved.
self.auto_tag_paths: set[str] = set()
# free-form per-movie attributes (e.g. "collection_sort"); usable in
# Filmotéka filename templates via name_context.
# miniDLNA filename templates via name_context.
self.attributes: dict[str, str] = {}
self.get_metadata()
@@ -50,9 +46,8 @@ class File:
self.tags = []
self.date = None
self.title = None
self.csfd_link = None
self.csfd_cache = None
self.csfd_tag_paths = set()
self.meta = None
self.auto_tag_paths = set()
self.attributes = {}
self.added = None
@@ -62,17 +57,16 @@ class File:
"ignored": self.ignored,
# ukládáme full_path tagů
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
# which of the tags came from ČSFD (provenance)
"csfd_tags": sorted(self.csfd_tag_paths),
# which of the tags were derived automatically (from the NFO)
"auto_tags": sorted(self.auto_tag_paths),
# date může být None
"date": self.date,
"title": self.title,
"csfd_link": self.csfd_link,
"attributes": self.attributes,
"added": self.added,
}
if self.csfd_cache is not None:
data["csfd_cache"] = {"version": CSFD_CACHE_VERSION, **self.csfd_cache}
if self.meta is not None:
data["meta"] = dict(self.meta)
return data
def _apply_record(self, data: dict) -> None:
@@ -81,16 +75,15 @@ class File:
self.tags = []
self.date = data.get("date", None)
self.title = data.get("title", None)
self.csfd_link = data.get("csfd_link", None)
# Legacy records have no provenance → treated as all-user (empty set)
self.csfd_tag_paths = set(data.get("csfd_tags", []))
# provenance of auto (NFO-derived) tags; legacy key "csfd_tags" honored
self.auto_tag_paths = set(data.get("auto_tags", data.get("csfd_tags", [])))
self.attributes = dict(data.get("attributes", {}))
self.added = data.get("added", None)
raw_cache = data.get("csfd_cache")
if raw_cache and raw_cache.get("version") == CSFD_CACHE_VERSION:
self.csfd_cache = {k: v for k, v in raw_cache.items() if k != "version"}
else:
self.csfd_cache = None
raw_meta = data.get("meta")
if raw_meta is None: # legacy: pull year/rating out of the old csfd cache
old = data.get("csfd_cache") or {}
raw_meta = {"year": old.get("year"), "rating": old.get("rating")} if old else None
self.meta = dict(raw_meta) if raw_meta else None
if not self.tagmanager:
return
@@ -126,13 +119,13 @@ class File:
return 0.0
def name_context(self) -> dict:
"""Fields for a Filmotéka filename template (see config tag schema).
"""Fields for a miniDLNA filename template (see config tag schema).
Provides title / year / rating / ext / stem / filename from the movie's
metadata (year & rating come from the ČSFD cache, year falling back to a
``Rok`` tag). Missing values render as an empty string.
metadata (year & rating come from the NFO cache, year falling back to a
``Rok`` tag, then the canonical filename). Missing values render empty.
"""
cache = self.csfd_cache or {}
cache = self.meta or {}
year = cache.get("year")
if year is None:
for t in self.tags:
@@ -195,78 +188,49 @@ class File:
self.date = date_str
self.save_metadata()
def set_csfd_link(self, url: str | None) -> None:
"""Nastaví CSFD odkaz nebo None. Invaliduje cache při změně odkazu."""
new_url = url if url else None
if new_url != self.csfd_link:
self.csfd_cache = None # odkaz se změnil — stará cache je neplatná
self.csfd_link = new_url
self.save_metadata()
def apply_nfo(self, nfo, schema: list[dict] | None = None) -> dict:
"""Regenerate the auto (NFO-derived) tags from the movie's NFO.
def get_cached_movie(self):
"""Vrátí CSFDMovie z cache, nebo None. Nevyžaduje síť."""
if self.csfd_cache is None:
return None
try:
from .csfd import CSFDMovie
return CSFDMovie.from_dict(self.csfd_cache)
except Exception:
return None
def apply_csfd_tags(self, schema: list[dict] | None = None) -> dict:
"""Načte data z ČSFD a přegeneruje **jen ČSFD tagy** podle tag schématu.
``schema`` je seznam definic kategorií (viz ``DEFAULT_TAG_SCHEMA``); pro
každý záznam s ``csfd_field`` vytvoří tagy z příslušného pole filmu
(s volitelným ``transform``). Tagy přidané uživatelem (mimo provenance
ČSFD) zůstávají nedotčené — proto změna ČSFD odkazu nepřepíše vlastní tagy.
Režie/herci se stále cachují, tag se z nich tvoří jen je-li v schématu.
Reads genres/year/countries/rating from ``nfo`` (``nfo.NfoData``) per the
tag schema's source fields and (re)creates those tags; user-added tags are
kept. Also caches year/rating (for folder grouping) and fills the title
when missing. ``nfo`` of ``None`` (no NFO yet) is a no-op.
Returns:
dict s klíči 'success', 'movie'/'error', 'tags_added'
dict with 'success' and 'tags_added'.
"""
if not self.csfd_link:
return {"success": False, "error": "CSFD odkaz není nastaven", "tags_added": []}
if nfo is None:
return {"success": False, "error": "NFO nenalezeno", "tags_added": []}
if schema is None:
from .config import DEFAULT_TAG_SCHEMA
schema = DEFAULT_TAG_SCHEMA
try:
from .csfd import fetch_movie
movie = fetch_movie(self.csfd_link)
self.csfd_cache = movie.to_dict()
except ImportError as e:
return {"success": False, "error": f"Chybí závislosti: {e}", "tags_added": []}
except Exception as e:
return {"success": False, "error": f"Chyba při načítání CSFD: {e}", "tags_added": []}
self.meta = {"year": nfo.year, "rating": nfo.rating}
from .csfd import csfd_field_values
# Drop previous ČSFD-sourced tags; keep user-added ones.
self.tags = [t for t in self.tags if t.full_path not in self.csfd_tag_paths]
self.csfd_tag_paths = set()
# Drop previous auto (NFO) tags; keep user-added ones.
self.tags = [t for t in self.tags if t.full_path not in self.auto_tag_paths]
self.auto_tag_paths = set()
tags_added: list[str] = []
for entry in schema:
field = entry.get("csfd_field")
field = entry.get("csfd_field") # schema key = source field name
if not field:
continue # user-only category
category = entry["category"]
for value in csfd_field_values(movie, field):
for value in nfo.field_values(field):
tag_obj = (self.tagmanager.add_tag(category, value)
if self.tagmanager else Tag(category, value))
if tag_obj not in self.tags:
self.tags.append(tag_obj)
tags_added.append(tag_obj.full_path)
self.csfd_tag_paths.add(tag_obj.full_path)
self.auto_tag_paths.add(tag_obj.full_path)
# Use the CSFD title if we don't have one yet
if movie.title and not self.title:
self.title = movie.title
if nfo.title and not self.title:
self.title = nfo.title
self.save_metadata()
return {"success": True, "movie": movie, "tags_added": tags_added}
return {"success": True, "tags_added": tags_added}
def add_tag(self, tag):
# tag může být Tag nebo string