Add movie-keyed pool index with multi-version movies, ČSFD title bridge, subtitles and miniDLNA library scoping
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"""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 typing import Optional
|
||||
|
||||
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) -> Optional[str]:
|
||||
"""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
|
||||
|
||||
|
||||
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) -> Optional[str]:
|
||||
"""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: Optional[str] = None
|
||||
tmdb_id: Optional[int] = 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
|
||||
attributes: dict[str, str] = field(default_factory=dict)
|
||||
meta: Optional[dict] = 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) -> Optional[MediaFile]:
|
||||
"""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
|
||||
Reference in New Issue
Block a user