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
+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,