The HTTP files browse, detail, people, collections and _shared do no database work of their own now. New plex/queries.py (the rows a page shows, a user's watch state through userscope), plex/service.py (the info pages' enrichment, a collection's editable flag, the row kept after a failed follow-up read, the person bio's background fetch; none commits), plex/library.py (the unified browse feed, its search and facets) and plex/collections.py (a collection's source, editability, card, certificate and member order). Every watch-state join in the moved code names its owner through owned_by. Pins 354 -> 178 in 4 files; basedpyright baseline 132 -> 118.
123 lines
4.8 KiB
Python
123 lines
4.8 KiB
Python
"""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
|