Migrate from Poetry and pyenv to uv

This commit is contained in:
2026-10-12 15:48:26 +02:00
parent c399a6aed3
commit c69f5f9166
93 changed files with 13068 additions and 2401 deletions
+29 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+128 -95
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -19,4 +19,4 @@ class Tag:
return False
def __hash__(self):
return hash((self.category, self.name))
return hash((self.category, self.name))
+4 -2
View File
@@ -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
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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()]