refactor(plex): the browse, detail, people and collection pages read through queries and write through the service

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.
This commit is contained in:
2026-10-19 22:11:20 +02:00
parent 533571f572
commit 3b0e365e26
19 changed files with 1513 additions and 1124 deletions
+30 -122
View File
@@ -4,45 +4,34 @@ import re
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.collation import collated, sorted_words
from app.collation import sorted_words
from app import logs
from app.auth import current_user
from app.db import get_db
from app.discover import queries
from app.discover import queries as discover_queries
from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
from app.plex import detail as plex_detail
from app.plex import people as plex_people
from app.plex import rollup, subtitles
from app.plex.query import title_col
from app.plex import queries, rollup, service, subtitles
from app.plex import tmdb as plex_tmdb
from app.plex.collections import collection_source
from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.routes.plex._shared import _norm_lang, item_or_404
from app.routes.plex.collections import _collection_source
from app.routes._common import not_found
if TYPE_CHECKING:
from app.plex.models import PlexItem, PlexSeason
log = logs.get_logger(__name__)
router = APIRouter(prefix="/api/plex", tags=["plex"])
def _lib_key(db: Session, library_id: int | None) -> str | None:
"""The Plex section key for a library id — the info/show page passes it to the admin collection
editor (in the unified cross-library view there's no single library prop otherwise)."""
if library_id is None:
return None
lib = db.get(PlexLibrary, library_id)
return lib.plex_key if lib else None
_SHOW_ENRICH_TTL_S = 300
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
@@ -106,39 +95,12 @@ def show_detail(
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
lang = _norm_lang(lang)
hu = lang == "hu"
sh = by_rating_key(db, PlexShow, rating_key)
sh = queries.show(db, rating_key)
if sh is None:
raise not_found("show")
seasons = (
db.execute(
select(PlexSeason)
.where(PlexSeason.show_id == sh.id)
.order_by(PlexSeason.season_number.asc().nullsfirst())
)
.scalars()
.all()
)
eps = (
db.execute(
select(PlexItem)
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
)
.scalars()
.all()
)
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user.id,
PlexState.item_id.in_([e.id for e in eps] or [0]),
)
).scalars()
}
seasons = queries.seasons(db, sh)
eps = queries.episodes(db, sh)
states = queries.states(db, user, [e.id for e in eps])
by_season: dict[int | None, list] = {}
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
for e in eps:
@@ -156,9 +118,7 @@ def show_detail(
meta, related_raw = _show_enrichment(db, sh.rating_key)
related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id, lang=lang)
if sh.people_enriched_at is None and meta:
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views
sh.people_enriched_at = datetime.now(timezone.utc)
service.enrich_show(db, sh, meta) # imdb/tagline/full_cast for future views
try:
db.commit()
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
@@ -191,7 +151,7 @@ def show_detail(
"year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}",
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
"library": _lib_key(db, sh.library_id),
"library": queries.library_key(db, sh.library_id),
"content_rating": sh.content_rating,
"rating": sh.rating,
"genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [],
@@ -227,13 +187,13 @@ def show_next(
Asked one show at a time, when a card is hovered or played, rather than carried on every card of a
grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays.
`next` is null for a show with no episodes."""
sh = by_rating_key(db, PlexShow, rating_key)
sh = queries.show(db, rating_key)
if sh is None:
raise not_found("show")
return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
return {"next": discover_queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
def _season_block(se: "PlexSeason", cards: list[dict]) -> dict:
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
season subpage, read from the same cached show-detail payload)."""
roll = rollup.rollup(cards)
@@ -258,12 +218,7 @@ def _relatedshow_cards(
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
if not rks:
return []
shows = {
s.rating_key: s
for s in db.execute(
select(PlexShow).where(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
).scalars()
}
shows = queries.shows_by_keys(db, rks[:60], exclude)
statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
out: list[dict] = []
seen: set[str] = set()
@@ -278,73 +233,36 @@ def _relatedshow_cards(
return out
def _collection_strips(db: Session, it: PlexItem, user_id: int, lang: str) -> list[dict]:
def _collection_strips(db: Session, it: "PlexItem", user_id: int, lang: str) -> list[dict]:
"""The collections this movie belongs to, each with its member titles (playable cards) — the
'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
keys = it.collection_keys or []
if not keys or it.kind != "movie":
return []
cols = {
c.rating_key: c
for c in db.execute(
select(PlexCollection).where(PlexCollection.rating_key.in_(keys))
).scalars()
}
cols = {c.rating_key: c for c in queries.collections_by_keys(db, keys)}
# Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
alpha = {w: i for i, w in enumerate(sorted_words(db, [c.title for c in cols.values()], lang))}
ordered = sorted(cols.values(), key=lambda c: (c.child_count or 0, alpha[c.title]))
out = []
for col in ordered:
rk = col.rating_key
members = (
db.execute(
select(PlexItem)
.where(
PlexItem.library_id == it.library_id,
PlexItem.kind == "movie",
PlexItem.collection_keys.contains([rk]),
)
.order_by(
PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang)
)
.limit(60)
)
.scalars()
.all()
)
ids = [m.id for m in members]
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0])
)
).scalars()
}
members = queries.library_movies_in(db, it.library_id, rk, lang, limit=60)
states = queries.states(db, user_id, [m.id for m in members])
out.append(
{
"id": rk,
"title": col.title,
"source": _collection_source(col),
"source": collection_source(col),
"items": [movie_card(m, states.get(m.id), lang=lang) for m in members],
}
)
return out
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
def _episode_nav(db: Session, it: "PlexItem") -> tuple[str | None, str | None]:
if it.kind != "episode" or it.show_id is None:
return None, None
keys = list(
db.execute(
select(PlexItem.rating_key)
.where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
).scalars()
)
keys = queries.episode_keys(db, it.show_id)
try:
i = keys.index(it.rating_key)
except ValueError:
@@ -391,13 +309,7 @@ def item_detail(
prev/next, and the per-user resume position + watch status."""
lang = _norm_lang(lang)
it = item_or_404(db, rating_key)
st = (
db.execute(
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
)
.scalars()
.first()
)
st = queries.state(db, user, it)
# Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/
# tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only
@@ -478,11 +390,7 @@ def item_detail(
# only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable
# and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show.
if it.kind == "movie" and it.people_enriched_at is None and meta:
cast = plex_people.cast_from_meta(meta)
if cast:
plex_people.enrich_row_people(db, it, cast)
plex_detail.apply_rich_detail(it, meta)
it.people_enriched_at = datetime.now(timezone.utc)
service.enrich_movie(db, it, meta)
try:
db.commit()
except Exception: # noqa: BLE001 — a failed enrich must not fail the page
@@ -515,8 +423,8 @@ def item_detail(
rich = plex_detail.rich_detail(meta)
prev_id, next_id = _episode_nav(db, it)
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
season = db.get(PlexSeason, it.season_id) if show and it.season_id else None
show = queries.show_by_id(db, it.show_id) if it.kind == "episode" and it.show_id else None
season = queries.season_by_id(db, it.season_id) if show and it.season_id else None
return {
"id": it.rating_key,
"kind": it.kind,
@@ -531,7 +439,7 @@ def item_detail(
"video_bit_depth": it.video_bit_depth,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art",
"library": _lib_key(db, it.library_id),
"library": queries.library_key(db, it.library_id),
"cast": rich.get("cast", []),
"imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"),