287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""The movie object — metadata first, files second.
|
|
|
|
A movie is identified by ``(title, year, tmdb_id)`` and owns **its files**, not
|
|
the other way round. That inversion is the point: the pool index used to key a
|
|
record by a file path, so metadata was a passenger on one particular file — and
|
|
everything about multi-version movies got awkward (which file "holds" the tags,
|
|
why renaming a grouped movie had to be blocked, why an import could silently
|
|
create a duplicate instead of a version).
|
|
|
|
Here the record is the movie; each playable file is a :class:`MediaFile` under
|
|
it, told apart by a free-text ``label`` (``3D HSBS``, ``4K``, ``Director's
|
|
Cut``, …). Where those files *live* is then a pure function of the record —
|
|
:meth:`Movie.desired_layout` — so promoting to a folder, demoting back, or
|
|
renaming the whole thing are all the same operation: recompute and move what
|
|
doesn't match.
|
|
|
|
Layout (Jellyfin's own conventions — https://jellyfin.org/docs/general/server/media/movies/)::
|
|
|
|
one video <Library>/Title (rok) [tmdbid-N].ext
|
|
two or more <Library>/Title (rok) [tmdbid-N]/
|
|
Title (rok) [tmdbid-N] - Výchozí.ext
|
|
Title (rok) [tmdbid-N].3D.HSBS.ext
|
|
|
|
Every file in a movie folder must start with the folder's exact name; both
|
|
``" - label"`` and ``".3D.FMT"`` are valid separators, so a 3D file keeps the
|
|
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 .naming import THREE_D_FORMATS, canonical_pool_stem, is_valid_year
|
|
|
|
# Roles a media file can have. Only playable video for now — artwork stays
|
|
# Jellyfin's job (it writes poster/fanart into the folder itself).
|
|
ROLE_VIDEO = "video"
|
|
|
|
# A grouped movie needs a label on every file (all of Jellyfin's documented
|
|
# examples label every version); this is what an unlabelled one gets.
|
|
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
|
|
)
|
|
|
|
|
|
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
|
|
version dialog and ``"hsbs"`` picked from a dropdown mean the same thing.
|
|
"""
|
|
match = _LABEL_3D_RE.match(str(label or "").strip())
|
|
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``.
|
|
|
|
- no label → ``stem.ext`` (a lone, plain video)
|
|
- 3D label → ``stem.3D.<FORMAT>.ext`` (Jellyfin's documented 3D form)
|
|
- anything else → ``stem - label.ext``
|
|
|
|
``grouped`` only decides whether an empty label is allowed: inside a movie
|
|
folder every file gets one, so it falls back to :data:`DEFAULT_VERSION_LABEL`.
|
|
"""
|
|
label = (label or "").strip()
|
|
if grouped and not label:
|
|
label = DEFAULT_VERSION_LABEL
|
|
if not label:
|
|
return f"{stem}{ext}"
|
|
fmt = normalize_3d_label(label)
|
|
if fmt:
|
|
return f"{stem}.3D.{fmt}{ext}"
|
|
return f"{stem} - {label}{ext}"
|
|
|
|
|
|
def identity_key(tmdb_id: object = None, title: str = "", year: object = None) -> str:
|
|
"""The pool-index key for a movie — ``tmdb-<id>``, else a title/year slug.
|
|
|
|
Keying by identity (rather than by file path) is what lets a second file of
|
|
the same film find its movie instead of becoming a separate entry.
|
|
"""
|
|
id_s = str(tmdb_id).strip() if tmdb_id is not None else ""
|
|
if id_s.isdigit():
|
|
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)
|
|
)
|
|
slug = re.sub(r"[^0-9a-z]+", "-", raw).strip("-")
|
|
return f"name-{slug or 'bez-nazvu'}"
|
|
|
|
|
|
@dataclass
|
|
class MediaFile:
|
|
"""One file belonging to a movie (today always a playable video)."""
|
|
|
|
path: Path
|
|
label: str = ""
|
|
role: str = ROLE_VIDEO
|
|
|
|
@property
|
|
def format_3d(self) -> str | None:
|
|
"""The 3D format this version's label denotes, if any."""
|
|
return normalize_3d_label(self.label)
|
|
|
|
def to_record(self, pool_dir: Path) -> dict:
|
|
"""Serialise for the pool index (path stored pool-relative, POSIX)."""
|
|
try:
|
|
rel = Path(self.path).relative_to(pool_dir).as_posix()
|
|
except ValueError:
|
|
rel = Path(self.path).as_posix()
|
|
return {"role": self.role, "label": self.label, "file": rel}
|
|
|
|
@classmethod
|
|
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 ""),
|
|
role=str(data.get("role") or ROLE_VIDEO),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class Movie:
|
|
"""A film in the pool: its identity, its metadata, and its media files."""
|
|
|
|
key: str
|
|
title: str = ""
|
|
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: str | None = None
|
|
date: str | None = None
|
|
added: str | None = None
|
|
attributes: dict[str, str] = field(default_factory=dict)
|
|
meta: dict | None = None
|
|
|
|
# ------------------------------------------------------------------
|
|
# Identity / shape
|
|
# ------------------------------------------------------------------
|
|
|
|
@property
|
|
def videos(self) -> list[MediaFile]:
|
|
"""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(),
|
|
)
|
|
|
|
@property
|
|
def is_grouped(self) -> bool:
|
|
"""True when the movie needs its own folder (more than one version)."""
|
|
return len(self.videos) > 1
|
|
|
|
@property
|
|
def stem(self) -> str:
|
|
"""Canonical ``Title (rok) [tmdbid-N]`` — the folder / filename base.
|
|
|
|
Falls back to the bare title when no four-digit year is known (a movie
|
|
can be imported without one).
|
|
"""
|
|
title = (self.title or "").replace("/", "-").replace("\\", "-").strip()
|
|
if not title:
|
|
title = self.key
|
|
if is_valid_year(self.year or ""):
|
|
return canonical_pool_stem(title, self.year, self.tmdb_id)
|
|
return title
|
|
|
|
@property
|
|
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
|
|
|
|
def desired_layout(self, pool_dir: Path) -> list[tuple[MediaFile, Path]]:
|
|
"""``(media, where it belongs)`` for every file, from the record alone.
|
|
|
|
The whole flat-vs-folder rule lives here: one video sits directly in
|
|
the library, two or more move into ``<Library>/<stem>/``. Callers move
|
|
whatever doesn't already match — which makes promotion, demotion and
|
|
renaming the same operation.
|
|
"""
|
|
base = Path(pool_dir) / self.library
|
|
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
|
|
),
|
|
)
|
|
for m in self.videos
|
|
]
|
|
|
|
# ------------------------------------------------------------------
|
|
# Index serialisation
|
|
# ------------------------------------------------------------------
|
|
|
|
def to_record(self, pool_dir: Path) -> dict:
|
|
data = {
|
|
"title": self.title,
|
|
"year": self.year,
|
|
"tmdb_id": self.tmdb_id,
|
|
"library": self.library,
|
|
"media": [m.to_record(pool_dir) for m in self.media],
|
|
"tags": [getattr(t, "full_path", t) for t in self.tags],
|
|
"auto_tags": sorted(self.auto_tag_paths),
|
|
"csfd_url": self.csfd_url,
|
|
"date": self.date,
|
|
"added": self.added,
|
|
"attributes": dict(self.attributes),
|
|
}
|
|
if self.meta is not None:
|
|
data["meta"] = dict(self.meta)
|
|
return data
|
|
|
|
@classmethod
|
|
def from_record(
|
|
cls, key: str, data: dict, pool_dir: Path, tagmanager=None
|
|
) -> 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
|
|
),
|
|
library=str(data.get("library") 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,
|
|
added=data.get("added") or None,
|
|
attributes=dict(data.get("attributes") or {}),
|
|
meta=(dict(data["meta"]) if data.get("meta") else None),
|
|
)
|
|
if tagmanager is not None:
|
|
for full_path in data.get("tags") or []:
|
|
if "/" in str(full_path):
|
|
category, name = str(full_path).split("/", 1)
|
|
movie.tags.append(tagmanager.add_tag(category, name))
|
|
return movie
|