"""What a collection is to the app: where it comes from, whether it may be edited, its card, its effective content rating, and how its members order — shared by the collection pages, the info page's collection strips and the admin editor.""" from collections.abc import Callable from datetime import date, datetime from typing import Any, NamedTuple from app.plex.models import PlexCollection # The most-restrictive → least ordering used to pick a collection's effective content rating from its # members (Plex shows one cert on the collection; we don't store it, so derive it). _CERT_RANK = { "G": 1, "TV-Y": 1, "TV-Y7": 1, "TV-G": 1, "PG": 2, "TV-PG": 2, "PG-13": 3, "TV-14": 3, "R": 4, "TV-MA": 4, "NC-17": 5, } def collection_cert(certs: list[str | None]) -> str | None: ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c] return max(ranked, key=lambda x: x[1])[0] if ranked else None _STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt") def collection_source(col: PlexCollection) -> str: first = (col.title or "").strip().split(" ", 1)[0].lower() if first in _STRIP_PROVIDERS: return first if col.smart: return "smart" return "collection" def collection_editable(c: PlexCollection) -> bool: """Effective editability: the admin marked it editable AND it's a plain manual collection — never smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex.""" return bool(c.editable) and not c.smart and collection_source(c) == "collection" def collection_card(c: PlexCollection) -> dict: return { "id": c.rating_key, "title": c.title, "summary": c.summary, "thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None, "child_count": c.child_count, "smart": c.smart, "source": collection_source(c), "editable": c.editable, "can_edit": collection_editable( c ), # whether the edit controls should be offered (admin UI) } class Member(NamedTuple): """One collection member flattened to everything the Sort control can order by. The page's two legs are different tables (movies and shows), so there is no single query to ORDER BY — the ordering is a pure list sort over these, which is also what makes it unit-testable.""" kind: str # "movie" | "show" obj: Any # PlexItem | PlexShow, carried through to the card serializer title: str # lower-cased; never NULL (the column is not nullable) year: int | None rating: float | None added: datetime | None release: date | None duration: int | None # movies only — a show has no single runtime last_watched: datetime | None # Sort key -> what to read off a Member. Pinned to `PLEX_SORT_KEYS` by a test: the collection page # shows the SAME Sort control as the browse grid, so a key this map is missing would be an option # that silently does nothing here. MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = { "added": lambda m: m.added, "lastwatched": lambda m: m.last_watched, "release": lambda m: m.release, "year": lambda m: m.year, "rating": lambda m: m.rating, "duration": lambda m: m.duration, "title": lambda m: m.title, } # What the page ordered by before it had a control, and still its default: a film series wants to be # read in release order, not by whenever Plex happened to ingest it. DEFAULT_MEMBER_SORT = "year" def sort_members( members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None ) -> list[Member]: """Order a collection's members by one of `PLEX_SORT_KEYS`. Two properties worth stating because they are easy to get wrong with a plain `reverse=True`: members with no value for the key sink to the END in BOTH directions (matching the browse grid's `nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside it. An unknown key falls back to the page's default instead of returning an arbitrary order. `alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles compare as Python strings, which puts every accented initial after "z".""" by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title) key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT]) if key is MEMBER_SORT_KEYS["title"]: key = by_title ordered = sorted(members, key=by_title) # stable secondary, applied first have = [m for m in ordered if key(m) is not None] missing = [m for m in ordered if key(m) is None] have.sort(key=key, reverse=sort_dir != "asc") return have + missing