Migrate from Poetry and pyenv to uv
This commit is contained in:
+29
-9
@@ -11,6 +11,7 @@ Two stores, by responsibility:
|
||||
describing a particular library: per-movie metadata **and** library-level
|
||||
settings (``tag_schema``, ``copyasis_folders``). It travels with the pool.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -41,12 +42,30 @@ GLOBAL_CONFIG_FILE = Path(__file__).parent.parent.parent / ".Curator.!gtag"
|
||||
# This lives in the code as the default; the effective schema is stored per-pool
|
||||
# in the index (``PoolIndex.settings["tag_schema"]``).
|
||||
DEFAULT_TAG_SCHEMA = [
|
||||
{"category": "Žánr", "csfd_field": "genres", "transform": None, "filmoteka_root": ""},
|
||||
{"category": "Rok", "csfd_field": "year", "transform": None, "filmoteka_root": "- Dle roku"},
|
||||
{"category": "Země původu", "csfd_field": "countries", "transform": None,
|
||||
"filmoteka_root": "- Dle země původu"},
|
||||
{"category": "Hodnocení", "csfd_field": "rating", "transform": "decade_band",
|
||||
"filmoteka_root": "- Dle hodnocení"},
|
||||
{
|
||||
"category": "Žánr",
|
||||
"csfd_field": "genres",
|
||||
"transform": None,
|
||||
"filmoteka_root": "",
|
||||
},
|
||||
{
|
||||
"category": "Rok",
|
||||
"csfd_field": "year",
|
||||
"transform": None,
|
||||
"filmoteka_root": "- Dle roku",
|
||||
},
|
||||
{
|
||||
"category": "Země původu",
|
||||
"csfd_field": "countries",
|
||||
"transform": None,
|
||||
"filmoteka_root": "- Dle země původu",
|
||||
},
|
||||
{
|
||||
"category": "Hodnocení",
|
||||
"csfd_field": "rating",
|
||||
"transform": "decade_band",
|
||||
"filmoteka_root": "- Dle hodnocení",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -60,8 +79,8 @@ DEFAULT_GLOBAL_CONFIG = {
|
||||
"last_folder": None,
|
||||
"sidebar_width": 250,
|
||||
"recent_folders": [],
|
||||
"pool_dir": None, # managed pool root (single source of truth)
|
||||
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
|
||||
"pool_dir": None, # managed pool root (single source of truth)
|
||||
"filmoteka_dir": None, # generated Filmotéka output (hardlink tree)
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +95,7 @@ def load_global_config() -> dict:
|
||||
if key not in config:
|
||||
config[key] = value
|
||||
return config
|
||||
except Exception:
|
||||
except OSError, ValueError, TypeError:
|
||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||
return DEFAULT_GLOBAL_CONFIG.copy()
|
||||
|
||||
@@ -91,6 +110,7 @@ def save_global_config(cfg: dict):
|
||||
# BACKWARDS COMPATIBILITY
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def load_config():
|
||||
"""Legacy function - returns global config"""
|
||||
return load_global_config()
|
||||
|
||||
+83
-33
@@ -18,6 +18,7 @@ solve the SHA-256 PoW the way the bundled worker JS does, and replay the
|
||||
request through a shared :class:`requests.Session` (so a batch pays the PoW
|
||||
cost only once).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -26,7 +27,6 @@ import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode, urljoin
|
||||
|
||||
from loguru import logger
|
||||
@@ -68,13 +68,13 @@ class CsfdFilm:
|
||||
"""The slice of a ČSFD film page Curator needs to reach a TMDb id."""
|
||||
|
||||
url: str
|
||||
csfd_id: Optional[int] = None
|
||||
csfd_id: int | None = None
|
||||
czech_title: str = ""
|
||||
original_title: str = ""
|
||||
year: Optional[str] = None # four-digit string, or None
|
||||
year: str | None = None # four-digit string, or None
|
||||
|
||||
@property
|
||||
def tmdb_query(self) -> tuple[str, Optional[str]]:
|
||||
def tmdb_query(self) -> tuple[str, str | None]:
|
||||
"""``(title, year)`` best suited for a TMDb search — original title first."""
|
||||
return (self.original_title or self.czech_title, self.year)
|
||||
|
||||
@@ -87,12 +87,11 @@ def dependencies_available() -> bool:
|
||||
def _check_dependencies() -> None:
|
||||
if not _HAS_DEPS:
|
||||
raise CsfdError(
|
||||
"ČSFD vyžaduje balíčky 'requests' a 'beautifulsoup4' "
|
||||
"(poetry install je doplní)."
|
||||
"ČSFD vyžaduje balíčky 'requests' a 'beautifulsoup4' (uv sync je doplní)."
|
||||
)
|
||||
|
||||
|
||||
def _extract_csfd_id(url: str) -> Optional[int]:
|
||||
def _extract_csfd_id(url: str) -> int | None:
|
||||
"""Pull the numeric film id out of a ČSFD URL, or ``None``."""
|
||||
match = re.search(r"/film/(\d+)", url)
|
||||
return int(match.group(1)) if match else None
|
||||
@@ -102,12 +101,13 @@ def _extract_csfd_id(url: str) -> Optional[int]:
|
||||
# Anubis proof-of-work (ported verbatim from the pre-Jellyfin csfd.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_json_blob(html: str, element_id: str):
|
||||
"""Return the parsed JSON from an Anubis ``<script id=...>`` blob, or None."""
|
||||
match = re.search(
|
||||
rf'<script id="{re.escape(element_id)}" type="application/json">(.*?)</script>',
|
||||
html,
|
||||
re.S,
|
||||
re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
@@ -190,19 +190,20 @@ def _get_page(session, url: str):
|
||||
# Page parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse_json_ld(soup) -> tuple[str, Optional[str]]:
|
||||
|
||||
def _parse_json_ld(soup) -> tuple[str, str | None]:
|
||||
"""``(name, year)`` from the film page's JSON-LD (name is the Czech title)."""
|
||||
for script in soup.find_all("script", type="application/ld+json"):
|
||||
try:
|
||||
data = json.loads(script.string or "")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
except json.JSONDecodeError, TypeError:
|
||||
continue
|
||||
if isinstance(data, list):
|
||||
data = next((d for d in data if d.get("@type") == "Movie"), None)
|
||||
if not isinstance(data, dict) or data.get("@type") != "Movie":
|
||||
continue
|
||||
name = (data.get("name") or "").strip()
|
||||
year: Optional[str] = None
|
||||
year: str | None = None
|
||||
for key in ("dateCreated", "datePublished"):
|
||||
match = re.search(r"(19|20)\d{2}", str(data.get(key) or ""))
|
||||
if match:
|
||||
@@ -212,7 +213,7 @@ def _parse_json_ld(soup) -> tuple[str, Optional[str]]:
|
||||
return "", None
|
||||
|
||||
|
||||
def _parse_origin(soup) -> tuple[Optional[str], Optional[str]]:
|
||||
def _parse_origin(soup) -> tuple[str | None, str | None]:
|
||||
"""``(origin_country, year)`` from the ``.origin`` line.
|
||||
|
||||
ČSFD separates values with inline bullet ``<span>`` elements (no commas),
|
||||
@@ -224,16 +225,15 @@ def _parse_origin(soup) -> tuple[Optional[str], Optional[str]]:
|
||||
return None, None
|
||||
raw = elem.get_text(separator="|", strip=True)
|
||||
tokens = [t.strip() for part in raw.split("|") for t in part.split(",")]
|
||||
country: Optional[str] = None
|
||||
year: Optional[str] = None
|
||||
country: str | None = None
|
||||
year: str | None = None
|
||||
for token in tokens:
|
||||
if not token:
|
||||
continue
|
||||
if year is None and re.fullmatch(r"(19|20)\d{2}", token):
|
||||
year = token
|
||||
continue
|
||||
if (country is None and not token[0].isdigit()
|
||||
and re.search(r"[^\W\d_]", token)):
|
||||
if country is None and not token[0].isdigit() and re.search(r"[^\W\d_]", token):
|
||||
country = token.split("/")[0].strip()
|
||||
return country, year
|
||||
|
||||
@@ -242,7 +242,7 @@ def _parse_origin(soup) -> tuple[Optional[str], Optional[str]]:
|
||||
_CZ_ORIGIN_ALIASES = {"česko", "československo"}
|
||||
|
||||
|
||||
def _parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]:
|
||||
def _parse_original_title(soup, origin_country: str | None) -> str | None:
|
||||
"""The film's original title from ``.film-names`` — or ``None`` to use the CZ title.
|
||||
|
||||
Each ``<li>`` carries a country/language flag (``img.flag`` ``title``) and a
|
||||
@@ -267,8 +267,9 @@ def _parse_original_title(soup, origin_country: Optional[str]) -> Optional[str]:
|
||||
wanted = origin_country.casefold()
|
||||
for country, text in names:
|
||||
here = country.casefold()
|
||||
if here == wanted or (wanted in _CZ_ORIGIN_ALIASES
|
||||
and here in _CZ_ORIGIN_ALIASES):
|
||||
if here == wanted or (
|
||||
wanted in _CZ_ORIGIN_ALIASES and here in _CZ_ORIGIN_ALIASES
|
||||
):
|
||||
return text
|
||||
return None
|
||||
|
||||
@@ -352,17 +353,19 @@ def search_films(query: str, limit: int = 5, session=None) -> list[CsfdFilm]:
|
||||
href = str(anchor.get("href") or "")
|
||||
if not _is_film_href(href):
|
||||
continue
|
||||
films.append(CsfdFilm(
|
||||
url=urljoin(CSFD_BASE_URL, href),
|
||||
csfd_id=_extract_csfd_id(href),
|
||||
czech_title=anchor.get_text(strip=True),
|
||||
))
|
||||
films.append(
|
||||
CsfdFilm(
|
||||
url=urljoin(CSFD_BASE_URL, href),
|
||||
csfd_id=_extract_csfd_id(href),
|
||||
czech_title=anchor.get_text(strip=True),
|
||||
)
|
||||
)
|
||||
if len(films) >= limit:
|
||||
break
|
||||
return films
|
||||
|
||||
|
||||
def find_csfd_url(query: str, session=None) -> Optional[str]:
|
||||
def find_csfd_url(query: str, session=None) -> str | None:
|
||||
"""Return the first ČSFD film URL matching ``query``, or ``None``.
|
||||
|
||||
ČSFD's full-text search treats a trailing year as a search term (not a
|
||||
@@ -382,13 +385,60 @@ def find_csfd_url(query: str, session=None) -> Optional[str]:
|
||||
|
||||
# Release-name tokens that mark the end of the actual title in a filename.
|
||||
_RELEASE_MARKERS = {
|
||||
"bluray", "blu-ray", "brrip", "bdrip", "bdremux", "remux", "webrip", "web",
|
||||
"web-dl", "webdl", "hdtv", "dvdrip", "dvd", "dvd5", "dvd9", "hdrip", "cam",
|
||||
"ts", "tc", "x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx",
|
||||
"aac", "ac3", "eac3", "dts", "dd5", "ddp5", "truehd", "atmos", "flac",
|
||||
"10bit", "8bit", "hdr", "hdr10", "dolby", "sdr", "proper", "repack",
|
||||
"extended", "unrated", "remastered", "imax", "multi", "dual", "complete",
|
||||
"internal", "limited", "uncut",
|
||||
"bluray",
|
||||
"blu-ray",
|
||||
"brrip",
|
||||
"bdrip",
|
||||
"bdremux",
|
||||
"remux",
|
||||
"webrip",
|
||||
"web",
|
||||
"web-dl",
|
||||
"webdl",
|
||||
"hdtv",
|
||||
"dvdrip",
|
||||
"dvd",
|
||||
"dvd5",
|
||||
"dvd9",
|
||||
"hdrip",
|
||||
"cam",
|
||||
"ts",
|
||||
"tc",
|
||||
"x264",
|
||||
"x265",
|
||||
"h264",
|
||||
"h265",
|
||||
"hevc",
|
||||
"avc",
|
||||
"xvid",
|
||||
"divx",
|
||||
"aac",
|
||||
"ac3",
|
||||
"eac3",
|
||||
"dts",
|
||||
"dd5",
|
||||
"ddp5",
|
||||
"truehd",
|
||||
"atmos",
|
||||
"flac",
|
||||
"10bit",
|
||||
"8bit",
|
||||
"hdr",
|
||||
"hdr10",
|
||||
"dolby",
|
||||
"sdr",
|
||||
"proper",
|
||||
"repack",
|
||||
"extended",
|
||||
"unrated",
|
||||
"remastered",
|
||||
"imax",
|
||||
"multi",
|
||||
"dual",
|
||||
"complete",
|
||||
"internal",
|
||||
"limited",
|
||||
"uncut",
|
||||
}
|
||||
_YEAR_RE = re.compile(r"^(19|20)\d{2}$")
|
||||
_RESOLUTION_RE = re.compile(r"^\d{3,4}p$|^[24]k$", re.IGNORECASE)
|
||||
@@ -407,7 +457,7 @@ def clean_filename_to_query(filename: str) -> str:
|
||||
tokens = [t for t in re.split(r"[.\s_]+", stem) if t]
|
||||
|
||||
title_words: list[str] = []
|
||||
year: Optional[str] = None
|
||||
year: str | None = None
|
||||
for token in tokens:
|
||||
bare = token.strip("()[]{}")
|
||||
if _YEAR_RE.match(bare):
|
||||
|
||||
+61
-17
@@ -1,4 +1,6 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .tag import Tag
|
||||
|
||||
|
||||
@@ -46,8 +48,12 @@ class File:
|
||||
stem is the version label. A flat file carries both in its own name.
|
||||
"""
|
||||
from .movie import identity_key
|
||||
from .naming import (is_canonical_pool_stem, parse_3d_format,
|
||||
parse_pool_stem, parse_tmdb_id)
|
||||
from .naming import (
|
||||
is_canonical_pool_stem,
|
||||
parse_3d_format,
|
||||
parse_pool_stem,
|
||||
parse_tmdb_id,
|
||||
)
|
||||
|
||||
parent = self.file_path.parent.name
|
||||
grouped = is_canonical_pool_stem(parent)
|
||||
@@ -58,8 +64,20 @@ class File:
|
||||
title = self.title or (parsed[0] if parsed else self.file_path.stem)
|
||||
|
||||
label = ""
|
||||
if grouped and self.file_path.stem.startswith(f"{parent} - "):
|
||||
label = self.file_path.stem[len(parent) + 3:]
|
||||
part = (
|
||||
re.fullmatch(
|
||||
rf"{re.escape(parent)}[\s._-]*(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{{1,2}})",
|
||||
self.file_path.stem,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if grouped
|
||||
else None
|
||||
)
|
||||
if part:
|
||||
# one part of a film split across files: "<folder>-part2"
|
||||
label = f"part{int(part.group(1))}"
|
||||
elif grouped and self.file_path.stem.startswith(f"{parent} - "):
|
||||
label = self.file_path.stem[len(parent) + 3 :]
|
||||
elif parse_3d_format(self.file_path.stem):
|
||||
label = parse_3d_format(self.file_path.stem) or ""
|
||||
return identity_key(tmdb_id, title, year), title, year, tmdb_id, label
|
||||
@@ -109,7 +127,9 @@ class File:
|
||||
"new": self.new,
|
||||
"ignored": self.ignored,
|
||||
# ukládáme full_path tagů
|
||||
"tags": [tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags],
|
||||
"tags": [
|
||||
tag.full_path if isinstance(tag, Tag) else tag for tag in self.tags
|
||||
],
|
||||
# which of the tags were derived automatically (from the NFO)
|
||||
"auto_tags": sorted(self.auto_tag_paths),
|
||||
# date může být None
|
||||
@@ -137,7 +157,9 @@ class File:
|
||||
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
|
||||
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:
|
||||
@@ -198,6 +220,7 @@ class File:
|
||||
if self.added:
|
||||
try:
|
||||
from datetime import datetime
|
||||
|
||||
return datetime.fromisoformat(self.added).timestamp()
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -223,20 +246,23 @@ class File:
|
||||
if year is None:
|
||||
# Last resort: recover it from a canonical "Title (YYYY)" filename.
|
||||
from .naming import parse_pool_stem
|
||||
|
||||
parsed = parse_pool_stem(self.file_path.stem)
|
||||
if parsed is not None:
|
||||
year = parsed[1]
|
||||
rating = cache.get("rating")
|
||||
# user attributes first; core fields take precedence over same-named keys
|
||||
context = dict(self.attributes)
|
||||
context.update({
|
||||
"title": self.title or self.file_path.stem,
|
||||
"year": "" if year is None else str(year),
|
||||
"rating": "" if rating is None else rating,
|
||||
"ext": self.file_path.suffix,
|
||||
"stem": self.file_path.stem,
|
||||
"filename": self.filename,
|
||||
})
|
||||
context.update(
|
||||
{
|
||||
"title": self.title or self.file_path.stem,
|
||||
"year": "" if year is None else str(year),
|
||||
"rating": "" if rating is None else rating,
|
||||
"ext": self.file_path.suffix,
|
||||
"stem": self.file_path.stem,
|
||||
"filename": self.filename,
|
||||
}
|
||||
)
|
||||
return context
|
||||
|
||||
def set_attribute(self, key: str, value: str | None) -> None:
|
||||
@@ -250,6 +276,19 @@ class File:
|
||||
self.attributes.pop(key, None)
|
||||
self.save_metadata()
|
||||
|
||||
def save_location(self) -> None:
|
||||
"""Persist a rename/move of this file without touching shared metadata.
|
||||
|
||||
Every version of a film holds its own in-memory copy of the movie
|
||||
record's shared fields (title, tags, ČSFD, …). A sibling loaded before
|
||||
someone changed those fields would write its stale copy back on a plain
|
||||
:meth:`save_metadata`. Re-reading the record first means only this
|
||||
file's own media entry — path and popisek — actually changes.
|
||||
"""
|
||||
if self.index is not None and self.index.find_movie_by_media(self.file_path):
|
||||
self.get_metadata()
|
||||
self.save_metadata()
|
||||
|
||||
def delete_metadata(self) -> None:
|
||||
"""Drop this file from its movie record; the last one takes the record.
|
||||
|
||||
@@ -316,6 +355,7 @@ class File:
|
||||
|
||||
if schema is None:
|
||||
from .config import DEFAULT_TAG_SCHEMA
|
||||
|
||||
schema = DEFAULT_TAG_SCHEMA
|
||||
|
||||
self.meta = {"year": nfo.year, "rating": nfo.rating}
|
||||
@@ -331,8 +371,11 @@ class File:
|
||||
continue # user-only category
|
||||
category = entry["category"]
|
||||
for value in nfo.field_values(field):
|
||||
tag_obj = (self.tagmanager.add_tag(category, value)
|
||||
if self.tagmanager else Tag(category, value))
|
||||
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)
|
||||
@@ -347,6 +390,7 @@ class File:
|
||||
def add_tag(self, tag):
|
||||
# tag může být Tag nebo string
|
||||
from .tag import Tag as TagClass
|
||||
|
||||
if isinstance(tag, str):
|
||||
if "/" in tag and self.tagmanager:
|
||||
category, name = tag.split("/", 1)
|
||||
@@ -375,4 +419,4 @@ class File:
|
||||
return
|
||||
if tag_obj in self.tags:
|
||||
self.tags.remove(tag_obj)
|
||||
self.save_metadata()
|
||||
self.save_metadata()
|
||||
|
||||
+981
-147
File diff suppressed because it is too large
Load Diff
+128
-95
@@ -17,12 +17,13 @@ Example:
|
||||
└── 1988/
|
||||
└── film.mkv (hardlink)
|
||||
"""
|
||||
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional, Dict, Set
|
||||
|
||||
from .file import File
|
||||
from .naming import is_valid_year, parse_pool_stem
|
||||
|
||||
@@ -30,7 +31,7 @@ from .naming import is_valid_year, parse_pool_stem
|
||||
class _SafeDict(dict):
|
||||
"""dict for str.format_map that leaves unknown fields as an empty string."""
|
||||
|
||||
def __missing__(self, key): # noqa: ANN001
|
||||
def __missing__(self, key):
|
||||
return ""
|
||||
|
||||
|
||||
@@ -54,14 +55,14 @@ class HardlinkManager:
|
||||
output_dir: Base directory where the tag-based structure will be created
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.created_links: List[Path] = []
|
||||
self.errors: List[Tuple[Path, str]] = []
|
||||
self.created_links: list[Path] = []
|
||||
self.errors: list[tuple[Path, str]] = []
|
||||
|
||||
def _resolve_roots(
|
||||
self,
|
||||
categories: Optional[List[str]],
|
||||
category_roots: Optional[Dict[str, str]],
|
||||
) -> Optional[Dict[str, str]]:
|
||||
categories: list[str] | None,
|
||||
category_roots: dict[str, str] | None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Normalize the two filter styles into a category → root-folder map.
|
||||
|
||||
``None`` means "all categories", folder == category name.
|
||||
@@ -86,7 +87,7 @@ class HardlinkManager:
|
||||
"""Filmotéka hardlink name: the clean title + extension (no year)."""
|
||||
return f"{self._clean_title(file_obj)}{file_obj.file_path.suffix}"
|
||||
|
||||
def _year_link_name(self, file_obj: File) -> Optional[str]:
|
||||
def _year_link_name(self, file_obj: File) -> str | None:
|
||||
"""``Title (YYYY).ext`` used to disambiguate a name collision, or None."""
|
||||
year = str(file_obj.name_context().get("year") or "").strip()
|
||||
if not is_valid_year(year):
|
||||
@@ -97,18 +98,18 @@ class HardlinkManager:
|
||||
"""Render a per-category filename template; fall back to the clean name."""
|
||||
try:
|
||||
rendered = template.format_map(_SafeDict(file_obj.name_context()))
|
||||
except (ValueError, KeyError, IndexError, AttributeError):
|
||||
except ValueError, KeyError, IndexError, AttributeError:
|
||||
return self._default_link_name(file_obj)
|
||||
rendered = rendered.replace("/", "-").replace("\\", "-").strip()
|
||||
return rendered or self._default_link_name(file_obj)
|
||||
|
||||
def _plan_links(
|
||||
self,
|
||||
files: List[File],
|
||||
roots: Optional[Dict[str, str]],
|
||||
transforms: Optional[Dict[str, str]],
|
||||
templates: Optional[Dict[str, str]],
|
||||
) -> List[Tuple[File, Path]]:
|
||||
files: list[File],
|
||||
roots: dict[str, str] | None,
|
||||
transforms: dict[str, str] | None,
|
||||
templates: dict[str, str] | None,
|
||||
) -> list[tuple[File, Path]]:
|
||||
"""Resolve the tag tree to a collision-free ``(file, link_path)`` plan.
|
||||
|
||||
The default hardlink name is the movie's **clean title** (no year). When
|
||||
@@ -122,7 +123,7 @@ class HardlinkManager:
|
||||
can never disagree on a name (which is what caused hardlink churn before).
|
||||
"""
|
||||
# (file_obj, target_dir, name, is_templated), one per file+folder
|
||||
entries: List[List] = []
|
||||
entries: list[list] = []
|
||||
seen_fd: set[tuple[str, str]] = set()
|
||||
for file_obj in files:
|
||||
for tag in file_obj.tags:
|
||||
@@ -136,10 +137,17 @@ class HardlinkManager:
|
||||
template = templates.get(tag.category) if templates else None
|
||||
if template:
|
||||
entries.append(
|
||||
[file_obj, target_dir, self._render_template(file_obj, template), True])
|
||||
[
|
||||
file_obj,
|
||||
target_dir,
|
||||
self._render_template(file_obj, template),
|
||||
True,
|
||||
]
|
||||
)
|
||||
else:
|
||||
entries.append(
|
||||
[file_obj, target_dir, self._default_link_name(file_obj), False])
|
||||
[file_obj, target_dir, self._default_link_name(file_obj), False]
|
||||
)
|
||||
|
||||
# Disambiguate default-named collisions (different movies) by their year.
|
||||
groups: dict[tuple[Path, str], set[str]] = defaultdict(set)
|
||||
@@ -154,10 +162,10 @@ class HardlinkManager:
|
||||
entry[2] = year_name
|
||||
|
||||
# Final pass: guarantee unique paths (stable numeric suffix on any tie).
|
||||
by_path: dict[tuple[Path, str], List[List]] = defaultdict(list)
|
||||
by_path: dict[tuple[Path, str], list[list]] = defaultdict(list)
|
||||
for entry in entries:
|
||||
by_path[(entry[1], entry[2])].append(entry)
|
||||
plan: List[Tuple[File, Path]] = []
|
||||
plan: list[tuple[File, Path]] = []
|
||||
for (target_dir, name), group in by_path.items():
|
||||
if len(group) == 1:
|
||||
plan.append((group[0][0], target_dir / name))
|
||||
@@ -169,7 +177,7 @@ class HardlinkManager:
|
||||
plan.append((entry[0], target_dir / fname))
|
||||
return plan
|
||||
|
||||
def _folder_value(self, tag, transforms: Optional[Dict[str, str]]) -> str:
|
||||
def _folder_value(self, tag, transforms: dict[str, str] | None) -> str:
|
||||
"""Folder name for a tag — its value run through the category transform.
|
||||
|
||||
The tag keeps its exact value; the grouping (e.g. rating → ten-point
|
||||
@@ -177,13 +185,16 @@ class HardlinkManager:
|
||||
"""
|
||||
if transforms and tag.category in transforms:
|
||||
from .transforms import apply_transform
|
||||
|
||||
return apply_transform(tag.name, transforms[tag.category])
|
||||
return tag.name
|
||||
|
||||
def _target_dir(
|
||||
self, tag, roots: Optional[Dict[str, str]],
|
||||
transforms: Optional[Dict[str, str]] = None,
|
||||
) -> Optional[Path]:
|
||||
self,
|
||||
tag,
|
||||
roots: dict[str, str] | None,
|
||||
transforms: dict[str, str] | None = None,
|
||||
) -> Path | None:
|
||||
"""Output directory for a tag, or None if its category is excluded."""
|
||||
if roots is None:
|
||||
folder = tag.category
|
||||
@@ -195,10 +206,12 @@ class HardlinkManager:
|
||||
return base / self._folder_value(tag, transforms)
|
||||
|
||||
def _managed_top_dirs(
|
||||
self, files: List[File], roots: Optional[Dict[str, str]],
|
||||
transforms: Optional[Dict[str, str]] = None,
|
||||
reserved_subfolders: Optional[Set[str]] = None,
|
||||
) -> Optional[Set[str]]:
|
||||
self,
|
||||
files: list[File],
|
||||
roots: dict[str, str] | None,
|
||||
transforms: dict[str, str] | None = None,
|
||||
reserved_subfolders: set[str] | None = None,
|
||||
) -> set[str] | None:
|
||||
"""Top-level output folders owned by the tag tree (None = all of them).
|
||||
|
||||
A category with a non-empty root owns that root folder. A category placed
|
||||
@@ -213,7 +226,7 @@ class HardlinkManager:
|
||||
return None
|
||||
grouping_roots = {folder for folder in roots.values() if folder}
|
||||
has_root_level = any(not folder for folder in roots.values())
|
||||
tops: Set[str] = set(grouping_roots)
|
||||
tops: set[str] = set(grouping_roots)
|
||||
|
||||
# genre folders currently present on the movies
|
||||
for cat, folder in roots.items():
|
||||
@@ -228,20 +241,23 @@ class HardlinkManager:
|
||||
if has_root_level and self.output_dir.exists():
|
||||
reserved = set(reserved_subfolders or set())
|
||||
for entry in self.output_dir.iterdir():
|
||||
if (entry.is_dir() and entry.name not in grouping_roots
|
||||
and entry.name not in reserved):
|
||||
if (
|
||||
entry.is_dir()
|
||||
and entry.name not in grouping_roots
|
||||
and entry.name not in reserved
|
||||
):
|
||||
tops.add(entry.name)
|
||||
return tops
|
||||
|
||||
def create_structure_for_files(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
files: list[File],
|
||||
categories: list[str] | None = None,
|
||||
dry_run: bool = False,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[int, int]:
|
||||
category_roots: dict[str, str] | None = None,
|
||||
category_transforms: dict[str, str] | None = None,
|
||||
category_filename_templates: dict[str, str] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Create hardlink structure for given files based on their tags.
|
||||
|
||||
@@ -267,7 +283,8 @@ class HardlinkManager:
|
||||
fail_count = 0
|
||||
|
||||
plan = self._plan_links(
|
||||
files, roots, category_transforms, category_filename_templates)
|
||||
files, roots, category_transforms, category_filename_templates
|
||||
)
|
||||
for file_obj, target_file in plan:
|
||||
try:
|
||||
if not dry_run:
|
||||
@@ -292,11 +309,8 @@ class HardlinkManager:
|
||||
return success_count, fail_count
|
||||
|
||||
def mirror_as_is(
|
||||
self,
|
||||
source_dir: Path,
|
||||
subfolder: str | None = None,
|
||||
dry_run: bool = False
|
||||
) -> Tuple[int, int]:
|
||||
self, source_dir: Path, subfolder: str | None = None, dry_run: bool = False
|
||||
) -> tuple[int, int]:
|
||||
"""Mirror a "copy-as-is" folder 1:1 into the output as a hardlinked clone.
|
||||
|
||||
Recreates the exact directory hierarchy of ``source_dir`` under
|
||||
@@ -363,8 +377,8 @@ class HardlinkManager:
|
||||
return True
|
||||
|
||||
def _fill_special_folder(
|
||||
self, chosen: List[File], subfolder: str, dry_run: bool
|
||||
) -> Tuple[int, int]:
|
||||
self, chosen: list[File], subfolder: str, dry_run: bool
|
||||
) -> tuple[int, int]:
|
||||
"""Empty ``subfolder`` and hardlink ``chosen`` files into it."""
|
||||
base = self.output_dir / subfolder
|
||||
|
||||
@@ -404,11 +418,11 @@ class HardlinkManager:
|
||||
|
||||
def generate_recently_added(
|
||||
self,
|
||||
files: List[File],
|
||||
files: list[File],
|
||||
count: int = 10,
|
||||
subfolder: str = "- Nově přidané",
|
||||
dry_run: bool = False,
|
||||
) -> Tuple[int, int]:
|
||||
) -> tuple[int, int]:
|
||||
"""(Re)fill ``subfolder`` with the ``count`` most recently added movies.
|
||||
|
||||
Ordering is by ``File.added_timestamp()`` (import time, ctime fallback),
|
||||
@@ -419,11 +433,11 @@ class HardlinkManager:
|
||||
|
||||
def generate_random_tips(
|
||||
self,
|
||||
files: List[File],
|
||||
files: list[File],
|
||||
count: int = 10,
|
||||
subfolder: str = "Tipy dne",
|
||||
dry_run: bool = False,
|
||||
) -> Tuple[int, int]:
|
||||
) -> tuple[int, int]:
|
||||
"""(Re)fill ``subfolder`` with ``count`` random files as hardlinks.
|
||||
|
||||
The folder is emptied first so each run picks a fresh random selection.
|
||||
@@ -491,12 +505,12 @@ class HardlinkManager:
|
||||
|
||||
def get_preview(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
) -> List[Tuple[Path, Path]]:
|
||||
files: list[File],
|
||||
categories: list[str] | None = None,
|
||||
category_roots: dict[str, str] | None = None,
|
||||
category_transforms: dict[str, str] | None = None,
|
||||
category_filename_templates: dict[str, str] | None = None,
|
||||
) -> list[tuple[Path, Path]]:
|
||||
"""
|
||||
Get a preview of what links would be created.
|
||||
|
||||
@@ -513,18 +527,19 @@ class HardlinkManager:
|
||||
"""
|
||||
roots = self._resolve_roots(categories, category_roots)
|
||||
plan = self._plan_links(
|
||||
files, roots, category_transforms, category_filename_templates)
|
||||
files, roots, category_transforms, category_filename_templates
|
||||
)
|
||||
return [(file_obj.file_path, target_file) for file_obj, target_file in plan]
|
||||
|
||||
def find_obsolete_links(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
reserved_subfolders: Optional[Set[str]] = None,
|
||||
) -> List[Tuple[Path, Path]]:
|
||||
files: list[File],
|
||||
categories: list[str] | None = None,
|
||||
category_roots: dict[str, str] | None = None,
|
||||
category_transforms: dict[str, str] | None = None,
|
||||
category_filename_templates: dict[str, str] | None = None,
|
||||
reserved_subfolders: set[str] | None = None,
|
||||
) -> list[tuple[Path, Path]]:
|
||||
"""
|
||||
Find hardlinks in the output directory that no longer match file tags.
|
||||
|
||||
@@ -543,7 +558,7 @@ class HardlinkManager:
|
||||
Returns:
|
||||
List of tuples (link_path, source_path) for obsolete links
|
||||
"""
|
||||
obsolete: List[Tuple[Path, Path]] = []
|
||||
obsolete: list[tuple[Path, Path]] = []
|
||||
|
||||
if not self.output_dir.exists():
|
||||
return obsolete
|
||||
@@ -580,7 +595,8 @@ class HardlinkManager:
|
||||
# a tag was removed, the movie was replaced (orphan inode), or the folder
|
||||
# itself is a leftover from a renamed layout.
|
||||
top_dirs = self._managed_top_dirs(
|
||||
files, roots, category_transforms, reserved_subfolders)
|
||||
files, roots, category_transforms, reserved_subfolders
|
||||
)
|
||||
for top in self.output_dir.iterdir():
|
||||
if not top.is_dir():
|
||||
continue
|
||||
@@ -599,22 +615,20 @@ class HardlinkManager:
|
||||
if link_inode in expected.get(link_file, ()):
|
||||
continue
|
||||
source = inode_to_file.get(link_inode)
|
||||
obsolete.append(
|
||||
(link_file, source.file_path if source else link_file)
|
||||
)
|
||||
obsolete.append((link_file, source.file_path if source else link_file))
|
||||
|
||||
return obsolete
|
||||
|
||||
def remove_obsolete_links(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
files: list[File],
|
||||
categories: list[str] | None = None,
|
||||
dry_run: bool = False,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
reserved_subfolders: Optional[Set[str]] = None,
|
||||
) -> Tuple[int, List[Path]]:
|
||||
category_roots: dict[str, str] | None = None,
|
||||
category_transforms: dict[str, str] | None = None,
|
||||
category_filename_templates: dict[str, str] | None = None,
|
||||
reserved_subfolders: set[str] | None = None,
|
||||
) -> tuple[int, list[Path]]:
|
||||
"""
|
||||
Remove hardlinks that no longer match file tags.
|
||||
|
||||
@@ -633,8 +647,13 @@ class HardlinkManager:
|
||||
Tuple of (removed_count, list_of_removed_paths)
|
||||
"""
|
||||
obsolete = self.find_obsolete_links(
|
||||
files, categories, category_roots, category_transforms,
|
||||
category_filename_templates, reserved_subfolders)
|
||||
files,
|
||||
categories,
|
||||
category_roots,
|
||||
category_transforms,
|
||||
category_filename_templates,
|
||||
reserved_subfolders,
|
||||
)
|
||||
removed_paths = []
|
||||
|
||||
if dry_run:
|
||||
@@ -654,14 +673,14 @@ class HardlinkManager:
|
||||
|
||||
def sync_structure(
|
||||
self,
|
||||
files: List[File],
|
||||
categories: Optional[List[str]] = None,
|
||||
files: list[File],
|
||||
categories: list[str] | None = None,
|
||||
dry_run: bool = False,
|
||||
category_roots: Optional[Dict[str, str]] = None,
|
||||
category_transforms: Optional[Dict[str, str]] = None,
|
||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||
reserved_subfolders: Optional[Set[str]] = None,
|
||||
) -> Tuple[int, int, int, int]:
|
||||
category_roots: dict[str, str] | None = None,
|
||||
category_transforms: dict[str, str] | None = None,
|
||||
category_filename_templates: dict[str, str] | None = None,
|
||||
reserved_subfolders: set[str] | None = None,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""
|
||||
Synchronize hardlink structure with current file tags.
|
||||
|
||||
@@ -684,31 +703,45 @@ class HardlinkManager:
|
||||
Tuple of (created, create_failed, removed, remove_failed)
|
||||
"""
|
||||
# First find how many obsolete links there are
|
||||
obsolete_count = len(self.find_obsolete_links(
|
||||
files, categories, category_roots, category_transforms,
|
||||
category_filename_templates, reserved_subfolders))
|
||||
obsolete_count = len(
|
||||
self.find_obsolete_links(
|
||||
files,
|
||||
categories,
|
||||
category_roots,
|
||||
category_transforms,
|
||||
category_filename_templates,
|
||||
reserved_subfolders,
|
||||
)
|
||||
)
|
||||
|
||||
# Remove obsolete links
|
||||
removed, removed_paths = self.remove_obsolete_links(
|
||||
files, categories, dry_run, category_roots, category_transforms,
|
||||
category_filename_templates, reserved_subfolders
|
||||
removed, _removed_paths = self.remove_obsolete_links(
|
||||
files,
|
||||
categories,
|
||||
dry_run,
|
||||
category_roots,
|
||||
category_transforms,
|
||||
category_filename_templates,
|
||||
reserved_subfolders,
|
||||
)
|
||||
remove_failed = obsolete_count - removed if not dry_run else 0
|
||||
|
||||
# Then create new links
|
||||
created, create_failed = self.create_structure_for_files(
|
||||
files, categories, dry_run, category_roots, category_transforms,
|
||||
category_filename_templates
|
||||
files,
|
||||
categories,
|
||||
dry_run,
|
||||
category_roots,
|
||||
category_transforms,
|
||||
category_filename_templates,
|
||||
)
|
||||
|
||||
return created, create_failed, removed, remove_failed
|
||||
|
||||
|
||||
def create_hardlink_structure(
|
||||
files: List[File],
|
||||
output_dir: Path,
|
||||
categories: Optional[List[str]] = None
|
||||
) -> Tuple[int, int, List[Tuple[Path, str]]]:
|
||||
files: list[File], output_dir: Path, categories: list[str] | None = None
|
||||
) -> tuple[int, int, list[tuple[Path, str]]]:
|
||||
"""
|
||||
Convenience function to create hardlink structure.
|
||||
|
||||
|
||||
+18
-5
@@ -13,11 +13,11 @@ Detects corrupted or unreadable video files. Two depths:
|
||||
Both tools ship with FFmpeg; ``ffmpeg_available`` reports whether they are on the
|
||||
PATH so callers can degrade gracefully.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
FFMPEG = "ffmpeg"
|
||||
FFPROBE = "ffprobe"
|
||||
@@ -48,7 +48,7 @@ def ffmpeg_available(deep: bool = True) -> bool:
|
||||
|
||||
|
||||
def check_video_integrity(
|
||||
path: Path, deep: bool = True, timeout: Optional[int] = None
|
||||
path: Path, deep: bool = True, timeout: int | None = None
|
||||
) -> IntegrityResult:
|
||||
"""Check a single video file for corruption.
|
||||
|
||||
@@ -76,13 +76,26 @@ def check_video_integrity(
|
||||
cmd = [FFMPEG, "-v", "error", "-xerror", "-i", str(path), "-f", "null", "-"]
|
||||
else:
|
||||
cmd = [
|
||||
FFPROBE, "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=codec_type", "-of", "csv=p=0", str(path),
|
||||
FFPROBE,
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_type",
|
||||
"-of",
|
||||
"csv=p=0",
|
||||
str(path),
|
||||
]
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, capture_output=True, text=True, errors="replace", timeout=timeout
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return IntegrityResult(path, ok=False, error="Kontrola vypršela (timeout)")
|
||||
|
||||
+40
-27
@@ -6,17 +6,19 @@ back what Jellyfin currently sees** (to verify the pool against it). Connection
|
||||
details come from ``.env`` — ``JELLYFIN_URL`` and ``JELLYFIN_API_KEY`` (created in
|
||||
the Jellyfin dashboard). Everything degrades gracefully when they are missing.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
try: # the values can also come from the real environment
|
||||
with contextlib.suppress(
|
||||
Exception
|
||||
): # the values can also come from the real environment
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
_TIMEOUT = 30
|
||||
|
||||
@@ -27,22 +29,22 @@ class JellyfinMovie:
|
||||
|
||||
jellyfin_id: str
|
||||
title: str
|
||||
year: Optional[int]
|
||||
tmdb_id: Optional[int]
|
||||
path: Optional[str]
|
||||
year: int | None
|
||||
tmdb_id: int | None
|
||||
path: str | None
|
||||
|
||||
|
||||
class JellyfinError(RuntimeError):
|
||||
"""A Jellyfin request could not be completed (no config, network, status)."""
|
||||
|
||||
|
||||
def base_url() -> Optional[str]:
|
||||
def base_url() -> str | None:
|
||||
"""The configured Jellyfin base URL (no trailing slash), or None."""
|
||||
url = os.getenv("JELLYFIN_URL")
|
||||
return url.strip().rstrip("/") if url and url.strip() else None
|
||||
|
||||
|
||||
def api_key() -> Optional[str]:
|
||||
def api_key() -> str | None:
|
||||
"""The configured Jellyfin API key, or None."""
|
||||
key = os.getenv("JELLYFIN_API_KEY")
|
||||
return key.strip() if key and key.strip() else None
|
||||
@@ -53,15 +55,20 @@ def api_available() -> bool:
|
||||
return bool(base_url() and api_key())
|
||||
|
||||
|
||||
def _request(method: str, path: str, params: Optional[dict] = None,
|
||||
timeout: Optional[int] = None) -> requests.Response:
|
||||
def _request(
|
||||
method: str, path: str, params: dict | None = None, timeout: int | None = None
|
||||
) -> requests.Response:
|
||||
url, key = base_url(), api_key()
|
||||
if not (url and key):
|
||||
raise JellyfinError("JELLYFIN_URL / JELLYFIN_API_KEY nejsou nastaveny (.env).")
|
||||
try:
|
||||
resp = requests.request(
|
||||
method, f"{url}{path}", headers={"X-Emby-Token": key},
|
||||
params=params, timeout=timeout or _TIMEOUT)
|
||||
method,
|
||||
f"{url}{path}",
|
||||
headers={"X-Emby-Token": key},
|
||||
params=params,
|
||||
timeout=timeout or _TIMEOUT,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise JellyfinError(f"Jellyfin nedostupný: {exc}") from exc
|
||||
if resp.status_code in (401, 403):
|
||||
@@ -71,7 +78,7 @@ def _request(method: str, path: str, params: Optional[dict] = None,
|
||||
return resp
|
||||
|
||||
|
||||
def server_version(timeout: Optional[int] = None) -> str:
|
||||
def server_version(timeout: int | None = None) -> str:
|
||||
"""Return the Jellyfin server version (a quick connectivity check)."""
|
||||
data = _request("GET", "/System/Info", timeout=timeout).json()
|
||||
return str(data.get("Version") or "?")
|
||||
@@ -84,22 +91,28 @@ def scan_all() -> None:
|
||||
|
||||
def fetch_movies() -> list[JellyfinMovie]:
|
||||
"""Read every movie Jellyfin currently has, with its TMDb id and path."""
|
||||
data = _request("GET", "/Items", params={
|
||||
"IncludeItemTypes": "Movie",
|
||||
"Recursive": "true",
|
||||
"Fields": "ProviderIds,Path,ProductionYear",
|
||||
"EnableTotalRecordCount": "false",
|
||||
}).json()
|
||||
data = _request(
|
||||
"GET",
|
||||
"/Items",
|
||||
params={
|
||||
"IncludeItemTypes": "Movie",
|
||||
"Recursive": "true",
|
||||
"Fields": "ProviderIds,Path,ProductionYear",
|
||||
"EnableTotalRecordCount": "false",
|
||||
},
|
||||
).json()
|
||||
movies: list[JellyfinMovie] = []
|
||||
for item in data.get("Items", []):
|
||||
provider = item.get("ProviderIds") or {}
|
||||
raw_tmdb = provider.get("Tmdb") or provider.get("tmdb")
|
||||
tmdb_id = int(raw_tmdb) if raw_tmdb and str(raw_tmdb).isdigit() else None
|
||||
movies.append(JellyfinMovie(
|
||||
jellyfin_id=str(item.get("Id")),
|
||||
title=item.get("Name") or "",
|
||||
year=item.get("ProductionYear"),
|
||||
tmdb_id=tmdb_id,
|
||||
path=item.get("Path"),
|
||||
))
|
||||
movies.append(
|
||||
JellyfinMovie(
|
||||
jellyfin_id=str(item.get("Id")),
|
||||
title=item.get("Name") or "",
|
||||
year=item.get("ProductionYear"),
|
||||
tmdb_id=tmdb_id,
|
||||
path=item.get("Path"),
|
||||
)
|
||||
)
|
||||
return movies
|
||||
|
||||
+17
-13
@@ -8,12 +8,12 @@ recover its **ČSFD link** (and title / year) from that sidecar — or from an o
|
||||
|
||||
This is read-only recovery for the import dialog; nothing here writes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Optional
|
||||
|
||||
from .pool_index import INDEX_FILENAME
|
||||
|
||||
@@ -25,13 +25,13 @@ _MAX_INDEX_WALK_UP = 4
|
||||
class LegacyMeta:
|
||||
"""What an old sidecar / index record can still tell us about a movie."""
|
||||
|
||||
csfd_url: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
year: Optional[str] = None # four-digit string
|
||||
source: str = "" # "sidecar" | "index"
|
||||
csfd_url: str | None = None
|
||||
title: str | None = None
|
||||
year: str | None = None # four-digit string
|
||||
source: str = "" # "sidecar" | "index"
|
||||
|
||||
|
||||
def _record_to_meta(record: object, source: str) -> Optional[LegacyMeta]:
|
||||
def _record_to_meta(record: object, source: str) -> LegacyMeta | None:
|
||||
"""Pull the useful fields out of a stored movie record (any schema version)."""
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
@@ -42,24 +42,28 @@ def _record_to_meta(record: object, source: str) -> Optional[LegacyMeta]:
|
||||
cache_raw = record.get("csfd_cache")
|
||||
cache = cache_raw if isinstance(cache_raw, dict) else {}
|
||||
raw_year = meta.get("year") or cache.get("year")
|
||||
year = str(raw_year).strip() if raw_year is not None and str(raw_year).strip().isdigit() else None
|
||||
year = (
|
||||
str(raw_year).strip()
|
||||
if raw_year is not None and str(raw_year).strip().isdigit()
|
||||
else None
|
||||
)
|
||||
if not (csfd or title or year):
|
||||
return None
|
||||
return LegacyMeta(csfd_url=csfd or None, title=title, year=year, source=source)
|
||||
|
||||
|
||||
def _from_sidecar(video_path: Path) -> Optional[LegacyMeta]:
|
||||
def _from_sidecar(video_path: Path) -> LegacyMeta | None:
|
||||
sidecar = video_path.parent / f".{video_path.name}{SIDECAR_SUFFIX}"
|
||||
if not sidecar.is_file():
|
||||
return None
|
||||
try:
|
||||
record = json.loads(sidecar.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, ValueError):
|
||||
except json.JSONDecodeError, OSError, ValueError:
|
||||
return None
|
||||
return _record_to_meta(record, "sidecar")
|
||||
|
||||
|
||||
def _lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> Optional[dict]:
|
||||
def _lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> dict | None:
|
||||
"""Find the record for ``video_path`` in an index's ``movies`` map.
|
||||
|
||||
Tries the exact pool-relative key first, then falls back to a unique
|
||||
@@ -76,14 +80,14 @@ def _lookup_in_index(movies: dict, video_path: Path, index_dir: Path) -> Optiona
|
||||
return matches[0] if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _from_legacy_index(video_path: Path) -> Optional[LegacyMeta]:
|
||||
def _from_legacy_index(video_path: Path) -> LegacyMeta | None:
|
||||
directory = video_path.parent
|
||||
for _ in range(_MAX_INDEX_WALK_UP + 1):
|
||||
index_path = directory / INDEX_FILENAME
|
||||
if index_path.is_file():
|
||||
try:
|
||||
data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError, ValueError):
|
||||
except json.JSONDecodeError, OSError, ValueError:
|
||||
return None
|
||||
movies = data.get("movies", data) if isinstance(data, dict) else None
|
||||
if not isinstance(movies, dict):
|
||||
@@ -96,7 +100,7 @@ def _from_legacy_index(video_path: Path) -> Optional[LegacyMeta]:
|
||||
return None
|
||||
|
||||
|
||||
def read_legacy_meta(video_path: Path) -> Optional[LegacyMeta]:
|
||||
def read_legacy_meta(video_path: Path) -> LegacyMeta | None:
|
||||
"""Recover ČSFD link / title / year from a pre-index layout, or ``None``.
|
||||
|
||||
Order: a per-file ``.{name}.!tag`` sidecar next to the video, then a
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from typing import List
|
||||
from .file import File
|
||||
|
||||
|
||||
class ListManager:
|
||||
def __init__(self):
|
||||
# 'name' nebo 'date'
|
||||
@@ -10,11 +10,12 @@ class ListManager:
|
||||
if mode in ("name", "date"):
|
||||
self.sort_mode = mode
|
||||
|
||||
def sort_files(self, files: List[File]) -> List[File]:
|
||||
def sort_files(self, files: list[File]) -> list[File]:
|
||||
if self.sort_mode == "name":
|
||||
return sorted(files, key=lambda f: f.filename.lower())
|
||||
else:
|
||||
# sort by date (None last) — nejnovější nahoře? Zde dávám None jako ""
|
||||
def date_key(f):
|
||||
return (f.date is None, f.date or "")
|
||||
return sorted(files, key=date_key)
|
||||
|
||||
return sorted(files, key=date_key)
|
||||
|
||||
+20
-6
@@ -1,5 +1,6 @@
|
||||
# Module header
|
||||
import sys
|
||||
|
||||
from .file import File
|
||||
from .tag_manager import TagManager
|
||||
|
||||
@@ -8,14 +9,17 @@ if __name__ == "__main__":
|
||||
|
||||
# Imports
|
||||
import subprocess
|
||||
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
|
||||
# Functions
|
||||
def load_icon(path) -> ImageTk.PhotoImage:
|
||||
img = Image.open(path)
|
||||
img = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||
return ImageTk.PhotoImage(img)
|
||||
|
||||
|
||||
def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
|
||||
"""
|
||||
Zjistí vertikální rozlišení videa a přiřadí tag Rozlišení/{výška}p.
|
||||
@@ -25,19 +29,29 @@ def add_video_resolution_tag(file_obj: File, tagmanager: TagManager):
|
||||
try:
|
||||
# ffprobe vrátí width a height ve formátu JSON
|
||||
result = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", path],
|
||||
[
|
||||
"ffprobe",
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height",
|
||||
"-of",
|
||||
"csv=p=0:s=x",
|
||||
path,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
check=True,
|
||||
)
|
||||
res = result.stdout.strip() # např. "1920x1080"
|
||||
if "x" not in res:
|
||||
return
|
||||
width, height = map(int, res.split("x"))
|
||||
_width, height = map(int, res.split("x"))
|
||||
tag_name = f"Rozlišení/{height}p"
|
||||
tag_obj = tagmanager.add_tag("Rozlišení", f"{height}p")
|
||||
file_obj.add_tag(tag_obj)
|
||||
print(f"Přiřazen tag {tag_name} k {file_obj.filename}")
|
||||
except Exception as e:
|
||||
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
|
||||
except (OSError, subprocess.SubprocessError, ValueError) as e:
|
||||
print(f"Chyba při získávání rozlišení videa {file_obj.filename}: {e}")
|
||||
|
||||
+9
-10
@@ -9,25 +9,25 @@ The classification is deliberately **conservative**: only an exact
|
||||
(diacritics-insensitive) title match with a matching year and a single candidate
|
||||
is ``auto``; everything else is ``ambiguous`` for the user to confirm.
|
||||
"""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchProposal:
|
||||
"""One movie's proposed TMDb match."""
|
||||
|
||||
file: object # core.file.File
|
||||
file: object # core.file.File
|
||||
current_name: str
|
||||
title: str
|
||||
year: Optional[str]
|
||||
status: str # "auto" | "ambiguous" | "no_match" | "error"
|
||||
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
|
||||
year: str | None
|
||||
status: str # "auto" | "ambiguous" | "no_match" | "error"
|
||||
chosen: object = None # tmdb.TmdbResult preselected for the user, or None
|
||||
candidates: list = field(default_factory=list) # list[tmdb.TmdbResult]
|
||||
error: str = ""
|
||||
note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)"
|
||||
note: str = "" # e.g. "přes ČSFD: The Shawshank Redemption (1994)"
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
@@ -38,13 +38,13 @@ def normalize(text: str) -> str:
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def _year_close(a: Optional[str], b: Optional[str]) -> bool:
|
||||
def _year_close(a: str | None, b: str | None) -> bool:
|
||||
if not (a and b and str(a).isdigit() and str(b).isdigit()):
|
||||
return False
|
||||
return abs(int(a) - int(b)) <= 1
|
||||
|
||||
|
||||
def classify(title: str, year: Optional[str], candidates: list) -> tuple[str, object]:
|
||||
def classify(title: str, year: str | None, candidates: list) -> tuple[str, object]:
|
||||
"""Return ``(status, chosen)`` for a title/year against TMDb candidates.
|
||||
|
||||
``auto`` requires an exact normalized-title match, a year within ±1, and that
|
||||
@@ -55,8 +55,7 @@ def classify(title: str, year: Optional[str], candidates: list) -> tuple[str, ob
|
||||
return "no_match", None
|
||||
nt = normalize(title)
|
||||
exact_year = [
|
||||
c for c in candidates
|
||||
if normalize(c.title) == nt and _year_close(c.year, year)
|
||||
c for c in candidates if normalize(c.title) == nt and _year_close(c.year, year)
|
||||
]
|
||||
if len(exact_year) == 1:
|
||||
return "auto", exact_year[0]
|
||||
|
||||
+57
-22
@@ -27,13 +27,13 @@ documented ``.3D.<FORMAT>`` spelling whether it sits flat or grouped.
|
||||
Subtitles are not media entries — they ride along with their video by filename
|
||||
stem (see :mod:`src.core.subtitles`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .naming import THREE_D_FORMATS, canonical_pool_stem, is_valid_year
|
||||
|
||||
@@ -47,10 +47,11 @@ DEFAULT_VERSION_LABEL = "Výchozí"
|
||||
|
||||
# "HSBS" / "hsbs" / "3D HSBS" / "3d.hsbs" / "3D_HSBS" → "HSBS"
|
||||
_LABEL_3D_RE = re.compile(
|
||||
rf"^(?:3d[\s._-]*)?({'|'.join(THREE_D_FORMATS)})$", re.IGNORECASE)
|
||||
rf"^(?:3d[\s._-]*)?({'|'.join(THREE_D_FORMATS)})$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def normalize_3d_label(label: object) -> Optional[str]:
|
||||
def normalize_3d_label(label: object) -> str | None:
|
||||
"""The 3D format a version label denotes (upper-case), or ``None``.
|
||||
|
||||
Lets a plain label double as the 3D marker — ``"3D HSBS"`` typed in the
|
||||
@@ -60,6 +61,29 @@ def normalize_3d_label(label: object) -> Optional[str]:
|
||||
return match.group(1).upper() if match else None
|
||||
|
||||
|
||||
# Jellyfin's multi-part ("stacking") keywords + a number: "part1", "CD 2", "pt-3".
|
||||
_LABEL_PART_RE = re.compile(
|
||||
r"^(?:part|pt|cd|dvd|disc|disk)[\s._-]*(\d{1,2})$", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def normalize_part_label(label: object) -> int | None:
|
||||
"""The part number a version label denotes, or ``None``.
|
||||
|
||||
A *part* is one piece of a film split across several files (played back
|
||||
to back as one movie), unlike a *version*, which is an alternative. Any of
|
||||
Jellyfin's stacking spellings counts — ``part2``, ``CD2``, ``pt 2`` — and
|
||||
Curator always writes it back as ``-part<N>`` (see :func:`part_label`).
|
||||
"""
|
||||
match = _LABEL_PART_RE.match(str(label or "").strip())
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def part_label(number: int) -> str:
|
||||
"""Canonical version label of part ``number`` — ``"part<N>"``."""
|
||||
return f"part{int(number)}"
|
||||
|
||||
|
||||
def media_filename(stem: str, label: str, ext: str, *, grouped: bool) -> str:
|
||||
"""Filename for one media file of a movie whose canonical stem is ``stem``.
|
||||
|
||||
@@ -92,8 +116,9 @@ def identity_key(tmdb_id: object = None, title: str = "", year: object = None) -
|
||||
return f"tmdb-{int(id_s)}"
|
||||
raw = f"{(title or '').strip()} {str(year or '').strip()}".strip().lower()
|
||||
# Fold diacritics so "Pelíšky" and "Pelisky" can't become two movies.
|
||||
raw = "".join(c for c in unicodedata.normalize("NFD", raw)
|
||||
if not unicodedata.combining(c))
|
||||
raw = "".join(
|
||||
c for c in unicodedata.normalize("NFD", raw) if not unicodedata.combining(c)
|
||||
)
|
||||
slug = re.sub(r"[^0-9a-z]+", "-", raw).strip("-")
|
||||
return f"name-{slug or 'bez-nazvu'}"
|
||||
|
||||
@@ -107,7 +132,7 @@ class MediaFile:
|
||||
role: str = ROLE_VIDEO
|
||||
|
||||
@property
|
||||
def format_3d(self) -> Optional[str]:
|
||||
def format_3d(self) -> str | None:
|
||||
"""The 3D format this version's label denotes, if any."""
|
||||
return normalize_3d_label(self.label)
|
||||
|
||||
@@ -120,7 +145,7 @@ class MediaFile:
|
||||
return {"role": self.role, "label": self.label, "file": rel}
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, data: dict, pool_dir: Path) -> "MediaFile":
|
||||
def from_record(cls, data: dict, pool_dir: Path) -> MediaFile:
|
||||
return cls(
|
||||
path=pool_dir / str(data.get("file", "")),
|
||||
label=str(data.get("label", "") or ""),
|
||||
@@ -134,19 +159,19 @@ class Movie:
|
||||
|
||||
key: str
|
||||
title: str = ""
|
||||
year: Optional[str] = None
|
||||
tmdb_id: Optional[int] = None
|
||||
year: str | None = None
|
||||
tmdb_id: int | None = None
|
||||
library: str = ""
|
||||
media: list[MediaFile] = field(default_factory=list)
|
||||
|
||||
# --- metadata that used to live on the "primary" File ---
|
||||
tags: list = field(default_factory=list)
|
||||
auto_tag_paths: set[str] = field(default_factory=set)
|
||||
csfd_url: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
added: Optional[str] = None
|
||||
csfd_url: str | None = None
|
||||
date: str | None = None
|
||||
added: str | None = None
|
||||
attributes: dict[str, str] = field(default_factory=dict)
|
||||
meta: Optional[dict] = None
|
||||
meta: dict | None = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Identity / shape
|
||||
@@ -157,7 +182,8 @@ class Movie:
|
||||
"""Playable versions, in a stable order (labelled ones by label)."""
|
||||
return sorted(
|
||||
(m for m in self.media if m.role == ROLE_VIDEO),
|
||||
key=lambda m: m.label.casefold())
|
||||
key=lambda m: m.label.casefold(),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_grouped(self) -> bool:
|
||||
@@ -179,7 +205,7 @@ class Movie:
|
||||
return title
|
||||
|
||||
@property
|
||||
def primary(self) -> Optional[MediaFile]:
|
||||
def primary(self) -> MediaFile | None:
|
||||
"""The version that represents the movie (first by :attr:`videos`)."""
|
||||
videos = self.videos
|
||||
return videos[0] if videos else None
|
||||
@@ -196,8 +222,13 @@ class Movie:
|
||||
grouped = self.is_grouped
|
||||
folder = base / self.stem if grouped else base
|
||||
return [
|
||||
(m, folder / media_filename(
|
||||
self.stem, m.label, Path(m.path).suffix, grouped=grouped))
|
||||
(
|
||||
m,
|
||||
folder
|
||||
/ media_filename(
|
||||
self.stem, m.label, Path(m.path).suffix, grouped=grouped
|
||||
),
|
||||
)
|
||||
for m in self.videos
|
||||
]
|
||||
|
||||
@@ -226,16 +257,20 @@ class Movie:
|
||||
@classmethod
|
||||
def from_record(
|
||||
cls, key: str, data: dict, pool_dir: Path, tagmanager=None
|
||||
) -> "Movie":
|
||||
) -> Movie:
|
||||
movie = cls(
|
||||
key=key,
|
||||
title=str(data.get("title") or ""),
|
||||
year=(str(data["year"]) if data.get("year") else None),
|
||||
tmdb_id=(int(data["tmdb_id"]) if str(data.get("tmdb_id") or "").isdigit()
|
||||
else None),
|
||||
tmdb_id=(
|
||||
int(data["tmdb_id"])
|
||||
if str(data.get("tmdb_id") or "").isdigit()
|
||||
else None
|
||||
),
|
||||
library=str(data.get("library") or ""),
|
||||
media=[MediaFile.from_record(m, pool_dir)
|
||||
for m in (data.get("media") or [])],
|
||||
media=[
|
||||
MediaFile.from_record(m, pool_dir) for m in (data.get("media") or [])
|
||||
],
|
||||
auto_tag_paths=set(data.get("auto_tags") or []),
|
||||
csfd_url=data.get("csfd_url") or None,
|
||||
date=data.get("date") or None,
|
||||
|
||||
+28
-3
@@ -15,6 +15,7 @@ writes it upper-case for a single canonical form.
|
||||
These helpers build, validate and parse the stem (the filename without its
|
||||
extension).
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
# Jellyfin's recognised 3D format tags (case-insensitive on read; Curator
|
||||
@@ -29,7 +30,8 @@ THREE_D_FORMATS = ("HSBS", "FSBS", "HTAB", "FTAB", "MVC")
|
||||
_POOL_STEM_RE = re.compile(
|
||||
r"^(?P<title>.+?) \((?P<year>\d{4})\)(?: \[tmdbid-(?P<id>\d+)\])?"
|
||||
r"(?:\.3D\.(?P<format3d>hsbs|fsbs|htab|ftab|mvc))?$",
|
||||
re.IGNORECASE)
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_valid_year(year: object) -> bool:
|
||||
@@ -38,7 +40,10 @@ def is_valid_year(year: object) -> bool:
|
||||
|
||||
|
||||
def canonical_pool_stem(
|
||||
title: str, year: object, tmdb_id: object = None, format_3d: object = None,
|
||||
title: str,
|
||||
year: object,
|
||||
tmdb_id: object = None,
|
||||
format_3d: object = None,
|
||||
) -> str:
|
||||
"""Build the canonical pool stem ``Title (YYYY)`` (+ `` [tmdbid-N]`` /
|
||||
``.3D.<FORMAT>`` if given).
|
||||
@@ -65,7 +70,8 @@ def canonical_pool_stem(
|
||||
if fmt not in THREE_D_FORMATS:
|
||||
raise ValueError(
|
||||
f"3D formát musí být jedno z {', '.join(THREE_D_FORMATS)}, "
|
||||
f"ne „{format_3d}“.")
|
||||
f"ne „{format_3d}“."
|
||||
)
|
||||
stem += f".3D.{fmt}"
|
||||
return stem
|
||||
|
||||
@@ -116,3 +122,22 @@ def year_from_filename(name: str) -> str | None:
|
||||
return paren.group(1)
|
||||
loose = re.findall(r"(?<!\d)(19\d{2}|20\d{2})(?!\d)", name)
|
||||
return loose[-1] if loose else None
|
||||
|
||||
|
||||
# "3D" + one of THREE_D_FORMATS, anywhere in a raw filename, bordered by a
|
||||
# non-alphanumeric character (or the string's edge) on each side so it can't
|
||||
# match inside an unrelated word.
|
||||
_THREE_D_ANYWHERE_RE = re.compile(
|
||||
rf"(?<![A-Za-z0-9])3d[\s._-]*({'|'.join(THREE_D_FORMATS)})(?![A-Za-z0-9])",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def three_d_format_from_filename(name: str) -> str | None:
|
||||
"""Best-effort 3D format guessed from a raw (release) filename, e.g.
|
||||
``Avatar.2009.3D.HSBS.BluRay.mkv`` → ``"HSBS"``. Unlike :func:`parse_3d_format`
|
||||
this doesn't require the rest of the name to be a canonical pool stem — used
|
||||
to pre-fill a version label while importing. Returns ``None`` if absent.
|
||||
"""
|
||||
match = _THREE_D_ANYWHERE_RE.search(name or "")
|
||||
return match.group(1).upper() if match else None
|
||||
|
||||
+12
-12
@@ -10,23 +10,23 @@ For a flat pool file ``Title (rok) [tmdbid].mkv`` the sidecar is
|
||||
Both are found. Missing or unparsable NFO → ``None`` (the movie simply has no
|
||||
genres until Jellyfin has scanned it).
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class NfoData:
|
||||
"""The bits of a movie ``.nfo`` Curator uses."""
|
||||
|
||||
title: Optional[str] = None
|
||||
year: Optional[str] = None # four-digit string
|
||||
title: str | None = None
|
||||
year: str | None = None # four-digit string
|
||||
genres: list[str] = field(default_factory=list)
|
||||
countries: list[str] = field(default_factory=list)
|
||||
rating: Optional[int] = None # 0–100 (NFO 0–10 scaled up), or None
|
||||
tmdb_id: Optional[int] = None
|
||||
csfd_url: Optional[str] = None # ČSFD film URL, if the NFO carries a csfd id
|
||||
rating: int | None = None # 0–100 (NFO 0–10 scaled up), or None
|
||||
tmdb_id: int | None = None
|
||||
csfd_url: str | None = None # ČSFD film URL, if the NFO carries a csfd id
|
||||
|
||||
def field_values(self, name: str) -> list[str]:
|
||||
"""Values for a tag-schema source field (genres/countries/year/rating)."""
|
||||
@@ -41,7 +41,7 @@ class NfoData:
|
||||
return []
|
||||
|
||||
|
||||
def find_nfo(video_path: Path) -> Optional[Path]:
|
||||
def find_nfo(video_path: Path) -> Path | None:
|
||||
"""Locate the ``.nfo`` for a movie: sibling ``<stem>.nfo`` or ``movie.nfo``."""
|
||||
video_path = Path(video_path)
|
||||
sibling = video_path.with_suffix(".nfo")
|
||||
@@ -57,7 +57,7 @@ def _texts(root: ET.Element, tag: str) -> list[str]:
|
||||
return [e.text.strip() for e in root.findall(tag) if e.text and e.text.strip()]
|
||||
|
||||
|
||||
def _parse_rating(root: ET.Element) -> Optional[int]:
|
||||
def _parse_rating(root: ET.Element) -> int | None:
|
||||
"""Read a 0–10 NFO rating and scale it to 0–100 (to match the old bands)."""
|
||||
raw = root.findtext("rating")
|
||||
if raw is None:
|
||||
@@ -71,7 +71,7 @@ def _parse_rating(root: ET.Element) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_tmdb_id(root: ET.Element) -> Optional[int]:
|
||||
def _parse_tmdb_id(root: ET.Element) -> int | None:
|
||||
for uid in root.findall("uniqueid"):
|
||||
if uid.get("type") == "tmdb" and uid.text and uid.text.strip().isdigit():
|
||||
return int(uid.text.strip())
|
||||
@@ -79,7 +79,7 @@ def _parse_tmdb_id(root: ET.Element) -> Optional[int]:
|
||||
return int(raw.strip()) if raw and raw.strip().isdigit() else None
|
||||
|
||||
|
||||
def _parse_csfd_url(root: ET.Element) -> Optional[str]:
|
||||
def _parse_csfd_url(root: ET.Element) -> str | None:
|
||||
"""A ČSFD film URL from the NFO, if any.
|
||||
|
||||
Czech Kodi scrapers record ČSFD as ``<uniqueid type="csfd">`` (a bare id or a
|
||||
@@ -102,14 +102,14 @@ def _parse_csfd_url(root: ET.Element) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def read_nfo(video_path: Path) -> Optional[NfoData]:
|
||||
def read_nfo(video_path: Path) -> NfoData | None:
|
||||
"""Read the movie's ``.nfo`` into :class:`NfoData`, or None if absent/broken."""
|
||||
path = find_nfo(video_path)
|
||||
if path is None:
|
||||
return None
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except (ET.ParseError, OSError):
|
||||
except ET.ParseError, OSError:
|
||||
return None
|
||||
|
||||
year = root.findtext("year")
|
||||
|
||||
+16
-9
@@ -24,6 +24,7 @@ index in place (backing the old file up first). Migration only rewrites
|
||||
metadata — no file on disk is moved, because a one-video movie is already in
|
||||
its v2 position.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
@@ -65,7 +66,7 @@ class PoolIndex:
|
||||
data = json.load(f)
|
||||
self.records = data.get("movies", {})
|
||||
self.settings = data.get("settings", {})
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except json.JSONDecodeError, OSError:
|
||||
self.records = {}
|
||||
self.settings = {}
|
||||
return
|
||||
@@ -103,9 +104,9 @@ class PoolIndex:
|
||||
tmdb_id = parse_tmdb_id(identity_stem)
|
||||
label = ""
|
||||
if grouped and path.stem.startswith(f"{parent} - "):
|
||||
label = path.stem[len(parent) + 3:]
|
||||
label = path.stem[len(parent) + 3 :]
|
||||
elif grouped:
|
||||
label = path.stem[len(parent):].lstrip(". -") or ""
|
||||
label = path.stem[len(parent) :].lstrip(". -") or ""
|
||||
|
||||
key = identity_key(tmdb_id, title, year)
|
||||
entry = {"role": ROLE_VIDEO, "label": label, "file": rel_path}
|
||||
@@ -129,8 +130,10 @@ class PoolIndex:
|
||||
migrated[key]["meta"] = record["meta"]
|
||||
|
||||
try:
|
||||
shutil.copy2(self.index_path,
|
||||
self.index_path.with_name(self.index_path.name + ".v1.bak"))
|
||||
shutil.copy2(
|
||||
self.index_path,
|
||||
self.index_path.with_name(self.index_path.name + ".v1.bak"),
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
self.records = migrated
|
||||
@@ -141,10 +144,14 @@ class PoolIndex:
|
||||
self.pool_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.index_path, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{"version": INDEX_VERSION,
|
||||
"settings": self.settings,
|
||||
"movies": self.records},
|
||||
f, indent=2, ensure_ascii=False,
|
||||
{
|
||||
"version": INDEX_VERSION,
|
||||
"settings": self.settings,
|
||||
"movies": self.records,
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def get_setting(self, key: str, default=None):
|
||||
|
||||
+153
-21
@@ -12,42 +12,82 @@ Episodes are matched by ``SxxEyy``. These helpers parse the episode number out o
|
||||
a raw filename, build the canonical episode/season/series names, and scan a
|
||||
series library into a ``Series → Season → Episode`` tree with a naming status.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .naming import canonical_pool_stem, parse_pool_stem, parse_tmdb_id
|
||||
from .naming import (
|
||||
canonical_pool_stem,
|
||||
is_canonical_pool_stem,
|
||||
parse_pool_stem,
|
||||
parse_tmdb_id,
|
||||
)
|
||||
|
||||
# SxxEyy / s1e2 / 1x02 — the episode marker in a filename. First match wins.
|
||||
_EPISODE_RE = re.compile(r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))")
|
||||
_EPISODE_RE = re.compile(
|
||||
r"(?:[Ss](\d{1,2})[\s._-]*[Ee](\d{1,3}))|(?:(\d{1,2})x(\d{1,3}))"
|
||||
)
|
||||
|
||||
# Jellyfin's own "stacking" tokens for one episode split across several
|
||||
# physical files (an old rip cut into CDs, a two-tape recording, …) — as
|
||||
# opposed to E01-E02, which is *two different* episodes in one file.
|
||||
_PART_TYPES = r"cd|dvd|part|pt|disc|disk"
|
||||
# Scanning a real filename: the token must sit at its own boundary right
|
||||
# before the end of the stem, so a title that happens to contain "cd" or
|
||||
# "part" as part of a word is never misread as a stacking suffix.
|
||||
_PART_RE = re.compile(
|
||||
rf"(?<![A-Za-z0-9])(?:{_PART_TYPES})[\s._-]?(\d{{1,2}})$", re.IGNORECASE
|
||||
)
|
||||
# The compact SxxEyy field a user types by hand (e.g. "S01E05P2") may also use
|
||||
# a bare "P" and no separator at all — safe only because it is matched against
|
||||
# just the tail *after* the recognised SxxEyy, never against a whole filename.
|
||||
_PART_RE_LOOSE = re.compile(
|
||||
rf"[\s._-]?(?:{_PART_TYPES}|p)[\s._-]?(\d{{1,2}})$", re.IGNORECASE
|
||||
)
|
||||
|
||||
# Files next to an episode that are not video (NFO / artwork / subtitles).
|
||||
_SIDECARS = {
|
||||
".nfo", ".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif", ".tbn",
|
||||
".srt", ".ass", ".ssa", ".sub", ".idx", ".vtt",
|
||||
".nfo",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".webp",
|
||||
".bmp",
|
||||
".gif",
|
||||
".tbn",
|
||||
".srt",
|
||||
".ass",
|
||||
".ssa",
|
||||
".sub",
|
||||
".idx",
|
||||
".vtt",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Episode:
|
||||
path: Path
|
||||
season: Optional[int]
|
||||
episode: Optional[int]
|
||||
season: int | None
|
||||
episode: int | None
|
||||
# Which physical part of the episode this file is (Jellyfin stacking,
|
||||
# e.g. an old recording split into two files) — None for a whole episode.
|
||||
part: int | None = None
|
||||
|
||||
@property
|
||||
def code(self) -> str:
|
||||
if self.season is None or self.episode is None:
|
||||
return "?"
|
||||
return f"S{self.season:02d}E{self.episode:02d}"
|
||||
base = f"S{self.season:02d}E{self.episode:02d}"
|
||||
return f"{base}.pt{self.part}" if self.part else base
|
||||
|
||||
|
||||
@dataclass
|
||||
class Series:
|
||||
folder: Path
|
||||
title: str
|
||||
year: Optional[str]
|
||||
tmdb_id: Optional[int]
|
||||
year: str | None
|
||||
tmdb_id: int | None
|
||||
library: str
|
||||
episodes: list[Episode] = field(default_factory=list)
|
||||
|
||||
@@ -55,13 +95,15 @@ class Series:
|
||||
"""Episodes grouped by season number (``-1`` = unrecognised)."""
|
||||
grouped: dict[int, list[Episode]] = {}
|
||||
for ep in self.episodes:
|
||||
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(ep)
|
||||
grouped.setdefault(ep.season if ep.season is not None else -1, []).append(
|
||||
ep
|
||||
)
|
||||
for eps in grouped.values():
|
||||
eps.sort(key=lambda e: (e.episode if e.episode is not None else 1_000_000))
|
||||
eps.sort(key=lambda e: e.episode if e.episode is not None else 1_000_000)
|
||||
return dict(sorted(grouped.items()))
|
||||
|
||||
|
||||
def parse_episode(name: str) -> Optional[tuple[int, int]]:
|
||||
def parse_episode(name: str) -> tuple[int, int] | None:
|
||||
"""Extract ``(season, episode)`` from a filename, or ``None``."""
|
||||
match = _EPISODE_RE.search(name)
|
||||
if not match:
|
||||
@@ -71,6 +113,38 @@ def parse_episode(name: str) -> Optional[tuple[int, int]]:
|
||||
return int(match.group(3)), int(match.group(4))
|
||||
|
||||
|
||||
def parse_episode_part(name: str) -> int | None:
|
||||
"""Part number implied by a Jellyfin stacking suffix in a filename, or ``None``.
|
||||
|
||||
Looks for *cd*, *dvd*, *part*, *pt*, *disc* or *disk* + a number sitting
|
||||
right before the extension (``Show S01E02.pt2.mkv``,
|
||||
``Show S01E02-part-2.mkv``, …) — the same tokens Jellyfin itself scans for.
|
||||
"""
|
||||
match = _PART_RE.search(Path(name).stem)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def parse_episode_code(text: str) -> tuple[int, int, int | None] | None:
|
||||
"""Parse a compact user-typed code: ``SxxEyy``, optionally + a part.
|
||||
|
||||
Accepts everything :func:`parse_episode` does, plus a trailing part marker
|
||||
right after it — ``S01E02P3``, ``S01E02 pt3``, ``S01E02.part.3``,
|
||||
``S01E02 cd2`` — for a file that is only *one part* of that episode.
|
||||
Returns ``(season, episode, part)`` with ``part`` ``None`` when absent, or
|
||||
``None`` when no ``SxxEyy`` is found at all.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
match = _EPISODE_RE.search(text)
|
||||
if not match:
|
||||
return None
|
||||
if match.group(1) is not None:
|
||||
season, episode = int(match.group(1)), int(match.group(2))
|
||||
else:
|
||||
season, episode = int(match.group(3)), int(match.group(4))
|
||||
part_match = _PART_RE_LOOSE.match(text[match.end() :])
|
||||
return season, episode, int(part_match.group(1)) if part_match else None
|
||||
|
||||
|
||||
def collect_episode_files(folder: Path) -> list[Path]:
|
||||
"""Every video file under ``folder`` (recursively), sidecars excluded, sorted.
|
||||
|
||||
@@ -82,7 +156,8 @@ def collect_episode_files(folder: Path) -> list[Path]:
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
f for f in folder.rglob("*")
|
||||
f
|
||||
for f in folder.rglob("*")
|
||||
if f.is_file() and f.suffix.lower() not in _SIDECARS
|
||||
)
|
||||
|
||||
@@ -97,6 +172,23 @@ def is_season_folder(name: str) -> bool:
|
||||
return name == "Specials" or re.fullmatch(r"Season \d{1,3}", name) is not None
|
||||
|
||||
|
||||
def looks_like_series_folder(folder: Path) -> bool:
|
||||
"""True when ``folder`` holds at least one ``Season NN``/``Specials`` subfolder.
|
||||
|
||||
That's the shape Curator always gives a series (episodes always land in a
|
||||
season folder, never loose at the series root) — a movie's version folder
|
||||
never has one, its files sit flat inside it. This is how a ``mixed``
|
||||
library tells the two apart even though both use the identical outer
|
||||
``Title (rok) [tmdbid]/`` name: :meth:`FileManager.is_movie_group_member`
|
||||
and :func:`scan_series` both check it before deciding which one a
|
||||
canonical-shaped subfolder is.
|
||||
"""
|
||||
folder = Path(folder)
|
||||
if not folder.is_dir():
|
||||
return False
|
||||
return any(p.is_dir() and is_season_folder(p.name) for p in folder.iterdir())
|
||||
|
||||
|
||||
# Characters not allowed in a filename on the common filesystems.
|
||||
_FS_UNSAFE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
|
||||
|
||||
@@ -107,17 +199,29 @@ def sanitize_title(title: str) -> str:
|
||||
return re.sub(r"\s+", " ", cleaned).strip(" .")
|
||||
|
||||
|
||||
def episode_filename(season: int, episode: int, series_title: str, ext: str) -> str:
|
||||
def episode_filename(
|
||||
season: int,
|
||||
episode: int,
|
||||
series_title: str,
|
||||
ext: str,
|
||||
*,
|
||||
part: int | None = None,
|
||||
) -> str:
|
||||
"""Canonical episode filename ``SxxEyy - {series title}.ext``.
|
||||
|
||||
The release string is deliberately **not** kept — identity is the ``SxxEyy``
|
||||
code, and Jellyfin fetches the episode title/metadata from TMDb (matched by
|
||||
the code + the series folder's ``[tmdbid-N]``). An empty title collapses to
|
||||
``SxxEyy.ext``.
|
||||
``SxxEyy.ext``. ``part`` marks one physical part of that episode — an old
|
||||
recording split across several files — with Jellyfin's own ``.ptN``
|
||||
stacking suffix right before the extension.
|
||||
"""
|
||||
code = f"S{season:02d}E{episode:02d}"
|
||||
body = sanitize_title(series_title)
|
||||
return f"{code} - {body}{ext}" if body else f"{code}{ext}"
|
||||
name = f"{code} - {body}" if body else code
|
||||
if part:
|
||||
name += f".pt{part}"
|
||||
return f"{name}{ext}"
|
||||
|
||||
|
||||
def series_folder_stem(title: str, year: object = None, tmdb_id: object = None) -> str:
|
||||
@@ -142,24 +246,46 @@ def is_canonical_episode(path: Path, series_title: str) -> bool:
|
||||
if parsed is None:
|
||||
return False
|
||||
season, episode = parsed
|
||||
expected = episode_filename(season, episode, series_title, path.suffix)
|
||||
part = parse_episode_part(path.name)
|
||||
expected = episode_filename(season, episode, series_title, path.suffix, part=part)
|
||||
if path.name != expected:
|
||||
return False
|
||||
return path.parent.name == season_folder_name(season)
|
||||
|
||||
|
||||
def scan_series(lib_dir: Path, library: str) -> list[Series]:
|
||||
def scan_series(
|
||||
lib_dir: Path,
|
||||
library: str,
|
||||
*,
|
||||
skip_movie_folders: bool = False,
|
||||
) -> list[Series]:
|
||||
"""Scan one series library into a list of :class:`Series`.
|
||||
|
||||
Each immediate subfolder of ``lib_dir`` is a series; its name yields the
|
||||
title/year/tmdbid. Video files anywhere below it are episodes (season/episode
|
||||
parsed from the filename). Non-video sidecars are skipped.
|
||||
|
||||
``skip_movie_folders=True`` (a ``mixed`` library, where a movie's version
|
||||
folder and a series folder both use the identical
|
||||
``Title (rok) [tmdbid]/`` shape) additionally skips a canonical-shaped
|
||||
folder with no ``Season NN``/``Specials`` inside it — that's a movie's
|
||||
version folder instead, left for :meth:`FileManager.load_pool_movies` to
|
||||
read. Left ``False`` (the default, a plain ``series``-kind library, which
|
||||
never holds movies) every subfolder is read as a series regardless of
|
||||
shape — a raw, not-yet-renamed import may have loose episode files
|
||||
sitting at the series root, with no season folder yet.
|
||||
"""
|
||||
lib_dir = Path(lib_dir)
|
||||
if not lib_dir.is_dir():
|
||||
return []
|
||||
series_list: list[Series] = []
|
||||
for folder in sorted(p for p in lib_dir.iterdir() if p.is_dir()):
|
||||
if (
|
||||
skip_movie_folders
|
||||
and is_canonical_pool_stem(folder.name)
|
||||
and not looks_like_series_folder(folder)
|
||||
):
|
||||
continue
|
||||
parsed = parse_pool_stem(folder.name)
|
||||
if parsed is not None:
|
||||
title, year = parsed[0], str(parsed[1])
|
||||
@@ -172,7 +298,13 @@ def scan_series(lib_dir: Path, library: str) -> list[Series]:
|
||||
if not f.is_file() or f.suffix.lower() in _SIDECARS:
|
||||
continue
|
||||
code = parse_episode(f.name)
|
||||
episodes.append(Episode(
|
||||
f, code[0] if code else None, code[1] if code else None))
|
||||
episodes.append(
|
||||
Episode(
|
||||
f,
|
||||
code[0] if code else None,
|
||||
code[1] if code else None,
|
||||
parse_episode_part(f.name),
|
||||
)
|
||||
)
|
||||
series_list.append(Series(folder, title, year, tmdb_id, library, episodes))
|
||||
return series_list
|
||||
|
||||
+19
-12
@@ -13,13 +13,13 @@ pool index. What Curator does own: copying a picked file into place with the
|
||||
right name (:func:`attach_subtitle`), and moving a subtitle along whenever its
|
||||
video gets renamed so it never silently detaches (:func:`rename_subtitles_with`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
SUBTITLE_EXTENSIONS = {".srt", ".ass", ".ssa", ".sub", ".vtt"}
|
||||
_FLAGS = {"forced", "sdh", "default"}
|
||||
@@ -31,8 +31,8 @@ class SubtitleFile:
|
||||
"""One subtitle sidecar found next to a video."""
|
||||
|
||||
path: Path
|
||||
language: Optional[str] = None # ISO 639-1/2 code, e.g. "cs"
|
||||
flag: Optional[str] = None # "forced" | "sdh" | "default"
|
||||
language: str | None = None # ISO 639-1/2 code, e.g. "cs"
|
||||
flag: str | None = None # "forced" | "sdh" | "default"
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
@@ -42,8 +42,10 @@ class SubtitleFile:
|
||||
|
||||
|
||||
def subtitle_filename(
|
||||
video_stem: str, ext: str, language: Optional[str] = None,
|
||||
flag: Optional[str] = None,
|
||||
video_stem: str,
|
||||
ext: str,
|
||||
language: str | None = None,
|
||||
flag: str | None = None,
|
||||
) -> str:
|
||||
"""Canonical subtitle filename: ``{video_stem}[.language][.flag]{ext}``."""
|
||||
parts = [video_stem]
|
||||
@@ -56,7 +58,7 @@ def subtitle_filename(
|
||||
|
||||
def _parse_subtitle_name(
|
||||
video_stem: str, filename: str
|
||||
) -> Optional[tuple[Optional[str], Optional[str]]]:
|
||||
) -> tuple[str | None, str | None] | None:
|
||||
"""``(language, flag)`` if ``filename`` is a subtitle sidecar of ``video_stem``."""
|
||||
candidate = Path(filename)
|
||||
if candidate.suffix.lower() not in SUBTITLE_EXTENSIONS:
|
||||
@@ -66,11 +68,11 @@ def _parse_subtitle_name(
|
||||
return None, None
|
||||
if not stem.startswith(f"{video_stem}."):
|
||||
return None
|
||||
tokens = [t for t in stem[len(video_stem) + 1:].split(".") if t]
|
||||
tokens = [t for t in stem[len(video_stem) + 1 :].split(".") if t]
|
||||
if not tokens:
|
||||
return None, None
|
||||
language: Optional[str] = None
|
||||
flag: Optional[str] = None
|
||||
language: str | None = None
|
||||
flag: str | None = None
|
||||
for token in tokens:
|
||||
low = token.lower()
|
||||
if low in _FLAGS:
|
||||
@@ -99,8 +101,12 @@ def find_subtitles(video_path: Path) -> list[SubtitleFile]:
|
||||
|
||||
|
||||
def attach_subtitle(
|
||||
video_path: Path, source: Path, *, language: Optional[str] = None,
|
||||
flag: Optional[str] = None, move: bool = False,
|
||||
video_path: Path,
|
||||
source: Path,
|
||||
*,
|
||||
language: str | None = None,
|
||||
flag: str | None = None,
|
||||
move: bool = False,
|
||||
) -> Path:
|
||||
"""Copy (or move) ``source`` next to ``video_path``, named to match it.
|
||||
|
||||
@@ -139,7 +145,8 @@ def rename_subtitles_with(old_video_path: Path, new_video_path: Path) -> list[Pa
|
||||
moved: list[Path] = []
|
||||
for sub in find_subtitles(old_video_path):
|
||||
target = new_video_path.parent / subtitle_filename(
|
||||
new_video_path.stem, sub.path.suffix, sub.language, sub.flag)
|
||||
new_video_path.stem, sub.path.suffix, sub.language, sub.flag
|
||||
)
|
||||
if sub.path == target:
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
+1
-1
@@ -19,4 +19,4 @@ class Tag:
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.category, self.name))
|
||||
return hash((self.category, self.name))
|
||||
|
||||
@@ -47,7 +47,9 @@ class TagManager:
|
||||
|
||||
def get_all_tags(self):
|
||||
"""Vrací list všech tagů full_path"""
|
||||
return [tag.full_path for tags in self.tags_by_category.values() for tag in tags]
|
||||
return [
|
||||
tag.full_path for tags in self.tags_by_category.values() for tag in tags
|
||||
]
|
||||
|
||||
def get_categories(self):
|
||||
return list(self.tags_by_category.keys())
|
||||
@@ -64,4 +66,4 @@ class TagManager:
|
||||
# Sort alphabetically for custom categories
|
||||
tags.sort(key=lambda t: t.name)
|
||||
|
||||
return tags
|
||||
return tags
|
||||
|
||||
+40
-26
@@ -8,17 +8,19 @@ Needs a free API key in ``TMDB_API_KEY`` (loaded from ``.env``). Everything
|
||||
degrades gracefully when the key or the network is missing: :func:`api_available`
|
||||
reports readiness and the lookups raise :class:`TmdbError` with a clear message.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
try: # optional — the key can also come from the real environment
|
||||
# optional — the key can also come from the real environment; dotenv is a
|
||||
# convenience, never required
|
||||
with contextlib.suppress(Exception):
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except Exception: # noqa: BLE001 — dotenv is a convenience, never required
|
||||
pass
|
||||
|
||||
_BASE = "https://api.themoviedb.org/3"
|
||||
_LANG = os.getenv("TMDB_LANGUAGE", "cs-CZ")
|
||||
@@ -31,7 +33,7 @@ class TmdbResult:
|
||||
|
||||
tmdb_id: int
|
||||
title: str
|
||||
year: Optional[str] # four-digit string, or None when TMDb has no date
|
||||
year: str | None # four-digit string, or None when TMDb has no date
|
||||
kind: str # "movie" | "tv"
|
||||
|
||||
|
||||
@@ -45,10 +47,10 @@ class TmdbDetails:
|
||||
|
||||
tmdb_id: int
|
||||
title: str
|
||||
year: Optional[str]
|
||||
year: str | None
|
||||
genres: list[str]
|
||||
countries: list[str]
|
||||
rating: Optional[int] # 0–100 (TMDb vote_average 0–10 scaled up)
|
||||
rating: int | None # 0–100 (TMDb vote_average 0–10 scaled up)
|
||||
|
||||
def field_values(self, name: str) -> list[str]:
|
||||
if name == "genres":
|
||||
@@ -66,7 +68,7 @@ class TmdbError(RuntimeError):
|
||||
"""A TMDb request could not be completed (no key, network, bad status)."""
|
||||
|
||||
|
||||
def api_key() -> Optional[str]:
|
||||
def api_key() -> str | None:
|
||||
"""The configured TMDb API key, or None."""
|
||||
key = os.getenv("TMDB_API_KEY")
|
||||
return key.strip() if key and key.strip() else None
|
||||
@@ -95,19 +97,21 @@ def _get(path: str, params: dict) -> dict:
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _year_of(date_str: Optional[str]) -> Optional[str]:
|
||||
def _year_of(date_str: str | None) -> str | None:
|
||||
if date_str and len(date_str) >= 4 and date_str[:4].isdigit():
|
||||
return date_str[:4]
|
||||
return None
|
||||
|
||||
|
||||
def lookup_movie(tmdb_id: int) -> Optional[TmdbResult]:
|
||||
def lookup_movie(tmdb_id: int) -> TmdbResult | None:
|
||||
"""Resolve a movie by its TMDb id → title + year, or None if it has no title."""
|
||||
data = _get(f"/movie/{int(tmdb_id)}", {})
|
||||
title = data.get("title") or data.get("original_title")
|
||||
if not title:
|
||||
return None
|
||||
return TmdbResult(int(data["id"]), title, _year_of(data.get("release_date")), "movie")
|
||||
return TmdbResult(
|
||||
int(data["id"]), title, _year_of(data.get("release_date")), "movie"
|
||||
)
|
||||
|
||||
|
||||
def movie_details(tmdb_id: int) -> TmdbDetails:
|
||||
@@ -115,31 +119,39 @@ def movie_details(tmdb_id: int) -> TmdbDetails:
|
||||
data = _get(f"/movie/{int(tmdb_id)}", {})
|
||||
title = data.get("title") or data.get("original_title") or ""
|
||||
genres = [g["name"] for g in (data.get("genres") or []) if g.get("name")]
|
||||
countries = [c["name"] for c in (data.get("production_countries") or [])
|
||||
if c.get("name")]
|
||||
countries = [
|
||||
c["name"] for c in (data.get("production_countries") or []) if c.get("name")
|
||||
]
|
||||
vote = data.get("vote_average")
|
||||
rating = round(float(vote) * 10) if vote else None
|
||||
return TmdbDetails(
|
||||
tmdb_id=int(data["id"]), title=title, year=_year_of(data.get("release_date")),
|
||||
genres=genres, countries=countries, rating=rating)
|
||||
tmdb_id=int(data["id"]),
|
||||
title=title,
|
||||
year=_year_of(data.get("release_date")),
|
||||
genres=genres,
|
||||
countries=countries,
|
||||
rating=rating,
|
||||
)
|
||||
|
||||
|
||||
def lookup_tv(tmdb_id: int) -> Optional[TmdbResult]:
|
||||
def lookup_tv(tmdb_id: int) -> TmdbResult | None:
|
||||
"""Resolve a series by its TMDb id → name + first-air year, or None."""
|
||||
data = _get(f"/tv/{int(tmdb_id)}", {})
|
||||
title = data.get("name") or data.get("original_name")
|
||||
if not title:
|
||||
return None
|
||||
return TmdbResult(int(data["id"]), title, _year_of(data.get("first_air_date")), "tv")
|
||||
return TmdbResult(
|
||||
int(data["id"]), title, _year_of(data.get("first_air_date")), "tv"
|
||||
)
|
||||
|
||||
|
||||
def lookup(tmdb_id: int, kind: str = "movie") -> Optional[TmdbResult]:
|
||||
def lookup(tmdb_id: int, kind: str = "movie") -> TmdbResult | None:
|
||||
"""Resolve a TMDb id as a movie (default) or a series (``kind="tv"``)."""
|
||||
return lookup_tv(tmdb_id) if kind == "tv" else lookup_movie(tmdb_id)
|
||||
|
||||
|
||||
def search_movie(
|
||||
query: str, year: Optional[str] = None, limit: int = 5
|
||||
query: str, year: str | None = None, limit: int = 5
|
||||
) -> list[TmdbResult]:
|
||||
"""Search movies by title (optionally narrowed by year); best matches first."""
|
||||
if not query.strip():
|
||||
@@ -153,14 +165,14 @@ def search_movie(
|
||||
title = item.get("title") or item.get("original_title")
|
||||
if title and item.get("id"):
|
||||
results.append(
|
||||
TmdbResult(int(item["id"]), title,
|
||||
_year_of(item.get("release_date")), "movie"))
|
||||
TmdbResult(
|
||||
int(item["id"]), title, _year_of(item.get("release_date")), "movie"
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def search_tv(
|
||||
query: str, year: Optional[str] = None, limit: int = 5
|
||||
) -> list[TmdbResult]:
|
||||
def search_tv(query: str, year: str | None = None, limit: int = 5) -> list[TmdbResult]:
|
||||
"""Search series by name (optionally narrowed by first-air year)."""
|
||||
if not query.strip():
|
||||
return []
|
||||
@@ -173,6 +185,8 @@ def search_tv(
|
||||
title = item.get("name") or item.get("original_name")
|
||||
if title and item.get("id"):
|
||||
results.append(
|
||||
TmdbResult(int(item["id"]), title,
|
||||
_year_of(item.get("first_air_date")), "tv"))
|
||||
TmdbResult(
|
||||
int(item["id"]), title, _year_of(item.get("first_air_date")), "tv"
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -4,8 +4,8 @@ Applied when building a folder name from a tag value — the tag keeps its exact
|
||||
value (e.g. rating 87 → tag "Hodnocení/87"), while the folder groups it
|
||||
(→ "…/80–89 %"). Config-driven and extensible via ``TAG_TRANSFORMS``.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def rating_band(rating: int) -> str:
|
||||
@@ -32,10 +32,10 @@ TAG_TRANSFORMS = {
|
||||
}
|
||||
|
||||
|
||||
def apply_transform(value: str, transform: Optional[str]) -> str:
|
||||
def apply_transform(value: str, transform: str | None) -> str:
|
||||
"""Apply a named tag-schema transform to a value, returning a string."""
|
||||
fn = TAG_TRANSFORMS.get(transform, str)
|
||||
try:
|
||||
return str(fn(value))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
except ValueError, TypeError, AttributeError:
|
||||
return str(value)
|
||||
|
||||
+2
-1
@@ -1,7 +1,8 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def list_files(folder_path: str | Path) -> list[Path]:
|
||||
folder = Path(folder_path)
|
||||
if not folder.is_dir():
|
||||
raise NotADirectoryError(f"{folder} není platná složka.")
|
||||
return [file_path for file_path in folder.rglob("*") if file_path.is_file()]
|
||||
return [file_path for file_path in folder.rglob("*") if file_path.is_file()]
|
||||
|
||||
Reference in New Issue
Block a user