"""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 /Title (rok) [tmdbid-N].ext two or more /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.`` 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`` (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"``.""" 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..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-``, 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 ``//``. 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