From 3b0e365e266f5c32581c3d1b7bd6c9a0617933e4 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 19 Oct 2026 22:11:20 +0200 Subject: [PATCH] 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. --- backend/.basedpyright/baseline.json | 114 ----- backend/app/plex/collections.py | 122 +++++ backend/app/plex/detail.py | 9 +- backend/app/plex/library.py | 473 +++++++++++++++++ backend/app/plex/queries.py | 382 ++++++++++++++ backend/app/plex/query.py | 2 +- backend/app/plex/service.py | 81 +++ backend/app/routes/plex/_shared.py | 12 +- backend/app/routes/plex/browse.py | 565 +++------------------ backend/app/routes/plex/collections.py | 317 ++---------- backend/app/routes/plex/detail.py | 152 ++---- backend/app/routes/plex/people.py | 109 +--- backend/tests/test_plex_collection_sort.py | 4 +- backend/tests/test_plex_people_enrich.py | 2 +- backend/tests/test_plex_person_bio.py | 20 +- backend/tests/test_plex_service.py | 264 ++++++++++ backend/tests/test_route_conventions.py | 2 +- backend/tests/test_unit_fences.py | 5 - docs/gates.md | 2 +- 19 files changed, 1513 insertions(+), 1124 deletions(-) create mode 100644 backend/app/plex/collections.py create mode 100644 backend/app/plex/library.py create mode 100644 backend/app/plex/queries.py create mode 100644 backend/app/plex/service.py create mode 100644 backend/tests/test_plex_service.py diff --git a/backend/.basedpyright/baseline.json b/backend/.basedpyright/baseline.json index 3fc631dc..3bb34af2 100644 --- a/backend/.basedpyright/baseline.json +++ b/backend/.basedpyright/baseline.json @@ -650,121 +650,7 @@ } } ], - "./app/routes/plex/browse.py": [ - { - "code": "reportArgumentType", - "range": { - "startColumn": 26, - "endColumn": 61, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 26, - "endColumn": 61, - "lineCount": 1 - } - }, - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 11, - "endColumn": 13, - "lineCount": 1 - } - }, - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 25, - "endColumn": 27, - "lineCount": 1 - } - }, - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 11, - "endColumn": 13, - "lineCount": 1 - } - }, - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 25, - "endColumn": 27, - "lineCount": 1 - } - }, - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 15, - "endColumn": 17, - "lineCount": 1 - } - }, - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 28, - "endColumn": 30, - "lineCount": 1 - } - }, - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 15, - "endColumn": 17, - "lineCount": 1 - } - }, - { - "code": "reportOptionalSubscript", - "range": { - "startColumn": 28, - "endColumn": 30, - "lineCount": 1 - } - } - ], "./app/routes/plex/collections.py": [ - { - "code": "reportAssignmentType", - "range": { - "startColumn": 11, - "endColumn": 39, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 76, - "endColumn": 80, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 75, - "endColumn": 79, - "lineCount": 1 - } - }, - { - "code": "reportArgumentType", - "range": { - "startColumn": 80, - "endColumn": 84, - "lineCount": 1 - } - }, { "code": "reportArgumentType", "range": { diff --git a/backend/app/plex/collections.py b/backend/app/plex/collections.py new file mode 100644 index 00000000..9bec8e3c --- /dev/null +++ b/backend/app/plex/collections.py @@ -0,0 +1,122 @@ +"""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 diff --git a/backend/app/plex/detail.py b/backend/app/plex/detail.py index 8830385a..4955e18d 100644 --- a/backend/app/plex/detail.py +++ b/backend/app/plex/detail.py @@ -1,10 +1,11 @@ """Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that the detail endpoints serve from the DB row. -Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so -the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent. -`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row -(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`).""" +Shared by the live info-page path (`plex.service.enrich_movie` / `enrich_show`) and the background +rich re-sync (plex.sync) so the two never diverge — the same one-parser rule that keeps `people.py`'s +cast handling consistent. `rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY +fields onto a movie/show row (genres/directors/studio/content_rating already land from the cheap +listing at `_apply_facet_fields`).""" from app.plex import people from app.plex.plaintext import plain_text diff --git a/backend/app/plex/library.py b/backend/app/plex/library.py new file mode 100644 index 00000000..b767535a --- /dev/null +++ b/backend/app/plex/library.py @@ -0,0 +1,473 @@ +"""The Plex browse grid's reads: the unified movies + shows feed, its search, and its facets. + +A user's watch state is joined through `userscope`; the feed and its facets share every filter +(`plex.query`), so a facet count is the size of what the grid would show. +""" + +from sqlalchemy import Boolean, Integer, String, and_, cast, func, literal, null, or_, select +from sqlalchemy.orm import Session, aliased + +from app.collation import collated, sorted_words +from app.models import User +from app.plex import rollup +from app.plex.cards import norm_lang +from app.plex.models import PlexItem, PlexShow, PlexState +from app.plex.query import ( + FIELD_COLUMNS, + PLAYBACK_CLASSES, + TS_CONFIG, + LibraryFilters, + apply_meta_filters, + apply_movie_state_filter, + apply_show_state_filter, + content_libs, + field_recheck, + genres_col, + meta_filter_conds, + movie_status_case, + playable_conds, + playback_class_expr, + show_status_case, + split_csv, + summary_col, + title_col, + vector_col, + vector_match, +) +from app.userscope import owned_by +from app.utils import to_tsquery_str + + +def sort_column(u, sort: str, lang: str = "en"): + """The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline + in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control + offers but this cannot order would quietly fall back to `added_at` and look like a broken control. + An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts + in the reader's alphabet (`collation`).""" + return { + "added": u.c.added_at, + "title": collated(u.c.title, lang), + "year": u.c.year, + "rating": u.c.rating, + "duration": u.c.duration_s, + "release": u.c.rel, + "lastwatched": u.c.last_watched, + }.get(sort, u.c.added_at) + + +def library_page( + db: Session, + user: User, + *, + scope: str, + q: str | None, + field: str, + sort: str, + sort_dir: str, + show: str, + lang: str, + offset: int, + limit: int, + f: LibraryFilters, +) -> tuple[int, list, list]: + """One page of the unified cross-library browse: (total, rows, episodes). `rows` mixes movies + and shows (`kind` says which), scoped to movie|show|both, with the shared filters/search/watch + state (a show's state is the aggregate of its episodes). On a search that also matches episodes + (and shows are in scope), `episodes` holds the matching ones as (item, state, show) — the + grouped 'Episodes' section. `lang` is already normalised.""" + p = f.as_dict() + movie_lib_ids, show_lib_ids = content_libs(db) + want_movies = scope in ("movie", "both") and bool(movie_lib_ids) + want_shows = scope in ("show", "both") and bool(show_lib_ids) + + ts = to_tsquery_str(q) if q else None + tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None + if field not in FIELD_COLUMNS and field != "all": + field = "all" + # `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for + # the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter. + grid_field = field != "episode" + recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None + + selects = [] + if want_movies and grid_field: + st = aliased(PlexState) + m_status = movie_status_case(st) + mq = ( + select( + PlexItem.rating_key.label("rk"), + literal("movie").label("kind"), + title_col(PlexItem, lang).label("title"), + PlexItem.year.label("year"), + PlexItem.rating.label("rating"), + # Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis. + func.left(summary_col(PlexItem, lang), 300).label("summary"), + PlexItem.added_at.label("added_at"), + PlexItem.originally_available_at.label("rel"), + PlexItem.duration_s.label("duration_s"), + PlexItem.playable.label("playable"), + PlexItem.transcode_hw.label("transcode_hw"), + cast(null(), Integer).label("season_count"), + func.coalesce(st.position_seconds, 0).label("position_seconds"), + m_status.label("status"), + literal(False).label("resumable"), + # "last watched" sort key: when the movie was last resumed, else when it was marked + # watched. NULL for never-touched movies → nullslast sinks them (see the sort dict). + func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"), + ( + func.ts_rank(vector_col(PlexItem, lang), tsq) + if tsq is not None + else literal(0.0) + ).label("rank"), + ) + .outerjoin(st, and_(st.item_id == PlexItem.id, owned_by(st.user_id, user))) + .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids)) + ) + mq = apply_movie_state_filter(mq, st, show) + mq = apply_meta_filters(mq, PlexItem, p, lang) + if f.duration_min is not None: + mq = mq.where(PlexItem.duration_s >= f.duration_min) + if f.duration_max is not None: + mq = mq.where(PlexItem.duration_s <= f.duration_max) + # Playback-class filter (movie-only — a show is an aggregate with no single class). + mq = mq.where(*playable_conds(split_csv(f.playable))) + if tsq is not None: + mq = mq.where(vector_match(PlexItem, lang, tsq)) + if recheck is not None and (narrow := recheck(PlexItem, field, tsq, lang)) is not None: + mq = mq.where(narrow) + selects.append(mq) + if want_shows and grid_field: + agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery() + tot = func.coalesce(agg.c.total, 0) + wat = func.coalesce(agg.c.watched, 0) + inp = func.coalesce(agg.c.inprog, 0) + s_status = show_status_case(tot, wat, inp) + sq = ( + select( + PlexShow.rating_key.label("rk"), + literal("show").label("kind"), + title_col(PlexShow, lang).label("title"), + PlexShow.year.label("year"), + PlexShow.rating.label("rating"), + func.left(summary_col(PlexShow, lang), 300).label("summary"), + PlexShow.added_at.label("added_at"), + PlexShow.originally_available_at.label("rel"), + cast(null(), Integer).label("duration_s"), + cast(null(), String).label("playable"), + cast(null(), Boolean).label("transcode_hw"), + PlexShow.child_count.label("season_count"), + literal(0).label("position_seconds"), + s_status.label("status"), + # An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart). + (inp > 0).label("resumable"), + # Show leg's "last watched": max(resume/watched time) across the show's episodes (see + # rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast. + agg.c.resumed_at.label("last_watched"), + ( + func.ts_rank(vector_col(PlexShow, lang), tsq) + if tsq is not None + else literal(0.0) + ).label("rank"), + ) + .outerjoin(agg, agg.c.show_id == PlexShow.id) + .where(PlexShow.library_id.in_(show_lib_ids)) + ) + sq = apply_show_state_filter(sq, tot, wat, inp, show) + sq = apply_meta_filters(sq, PlexShow, p, lang) + if tsq is not None: + sq = sq.where(vector_match(PlexShow, lang, tsq)) + if recheck is not None and (narrow := recheck(PlexShow, field, tsq, lang)) is not None: + sq = sq.where(narrow) + selects.append(sq) + + total = 0 + rows: list = [] + if selects: + union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) + u = union.subquery() + total = db.scalar(select(func.count()).select_from(u)) or 0 + + if tsq is not None: + order = [u.c.rank.desc()] + else: + col = sort_column(u, sort, lang) + order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] + order.append(u.c.rk.desc()) + rows = list(db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all()) + + # Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour) + # and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes + # target the movie/show grid, so they suppress episodes. + eps: list = [] + if tsq is not None and want_shows and field in ("all", "episode"): + ep_st = aliased(PlexState) + # In HU an episode is found by its Hungarian title too (the overlay fills it) — and + # still by its English one, like `vector_match` does for the grid. + if field == "episode": + ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, "")) + ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, "")) + else: + ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu + hit = ep_vec.op("@@")(tsq) + if lang == "hu": + hit = or_(ep_vec_hu.op("@@")(tsq), hit) + ep_vec = ep_vec_hu + eps = list( + db.execute( + select(PlexItem, ep_st, PlexShow) + .outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, owned_by(ep_st.user_id, user))) + .outerjoin(PlexShow, PlexShow.id == PlexItem.show_id) + .where( + PlexItem.kind == "episode", + PlexItem.library_id.in_(show_lib_ids), + hit, + ) + .order_by(func.ts_rank(ep_vec, tsq).desc()) + .limit(24) + ).all() + ) + + return total, rows, eps + + +def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: LibraryFilters) -> dict: + """Available filter values for the unified library (scope movie|show|both), ADAPTED to the active + filters (faceted search): each group's values/bounds are computed with all the OTHER active + filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while + a multi-select group still lets you add more. Merged across the scope's libraries; duration is + movie-only. (Search text is not factored in — facets track the sidebar filters.) `lang` is + normalised here, after the empty answer.""" + empty = { + "genres": [], + "content_ratings": [], + "studios": [], + "playable": [], + "year_min": None, + "year_max": None, + "rating_max": None, + "duration_min": None, + "duration_max": None, + } + all_movie_ids, all_show_ids = content_libs(db) + movie_ids = all_movie_ids if scope in ("movie", "both") else [] + show_ids = all_show_ids if scope in ("show", "both") else [] + if not movie_ids and not show_ids: + return empty + lang = norm_lang(lang) + p = f.as_dict() + + uid = user.id + + # The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a + # full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups + # (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call. + # The same filter on the same rows, so the counts do not change + # (test_plex_facets_grid_parity). + shows_in_scope: list[int] | None = None + if show_ids and show in ("watched", "in_progress", "unwatched"): + agg = rollup.agg_subquery(db, uid).subquery() + in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id) + in_scope = apply_show_state_filter( + in_scope, + func.coalesce(agg.c.total, 0), + func.coalesce(agg.c.watched, 0), + func.coalesce(agg.c.inprog, 0), + show, + ) + shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids)))) + + def wsq(query, model, ids, pp, kind_movie, with_dur): + """Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a + facet aggregation query, so the facets reflect exactly the visible result set.""" + query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang)) + if kind_movie: + query = query.where(model.kind == "movie") + if with_dur and pp.get("duration_min") is not None: + query = query.where(model.duration_s >= pp["duration_min"]) + if with_dur and pp.get("duration_max") is not None: + query = query.where(model.duration_s <= pp["duration_max"]) + # Playback-class filter (movie-only) — applied for the OTHER groups so an active class + # narrows them; the playable facet itself passes pp with playable=None (self-exclusion). + query = query.where(*playable_conds(split_csv(pp.get("playable")))) + # Join watch-state and reuse the grid's exact movie state filter (unified_library) — + # hidden movies never appear in the grid, so they must not inflate facet counts/bounds either. + st = aliased(PlexState) + query = query.outerjoin(st, and_(st.item_id == model.id, owned_by(st.user_id, uid))) + query = apply_movie_state_filter(query, st, show) + elif shows_in_scope is not None: + query = query.where(model.id.in_(shows_in_scope)) + return query + + genre_counts: dict[str, int] = {} + cr_counts: dict[str, int] = {} + studio_counts: dict[str, int] = {} + playable_counts: dict[str, int] = {} + years: list[int] = [] + ratings: list[float] = [] + durs: list[int] = [] + + def per_table(model, ids, kind_movie: bool) -> None: + if not ids: + return + # Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid + # OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we + # offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND + # the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since + # every matching title carries them, so they stay togglable.) + genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None} + # Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON + # `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and + # jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") — + # so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the + # whole facets endpoint. Mirror the exact guard people.py already applies to the same columns. + # In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)). + gcol = genres_col(model, lang) + expanded = ( + wsq( + select(func.jsonb_array_elements_text(gcol).label("g")), + model, + ids, + genre_pp, + kind_movie, + True, + ) + .where(func.jsonb_typeof(gcol) == "array") + .subquery() + ) + for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all(): + genre_counts[g] = genre_counts.get(g, 0) + c + # Content ratings — exclude its own. + crq = ( + wsq( + select(model.content_rating, func.count()), + model, + ids, + {**p, "content_ratings": None}, + kind_movie, + True, + ) + .where(model.content_rating.isnot(None)) + .group_by(model.content_rating) + ) + for cr, c in db.execute(crq).all(): + cr_counts[cr] = cr_counts.get(cr, 0) + c + # Studios — exclude its own (each offered studio is a valid OR-addition that only widens). + stq = ( + wsq( + select(model.studio, func.count()), + model, + ids, + {**p, "studios": None}, + kind_movie, + True, + ) + .where(model.studio.isnot(None)) + .group_by(model.studio) + ) + for s, c in db.execute(stq).all(): + studio_counts[s] = studio_counts.get(s, 0) + c + # Playback class (movie-only) — exclude its own so each class stays an addable OR option. + if kind_movie: + pcls = playback_class_expr() + pcq = ( + wsq( + select(pcls.label("cls"), func.count()), + model, + ids, + {**p, "playable": None}, + kind_movie, + True, + ) + .where(pcls.isnot(None)) + .group_by(pcls) + ) + for cls, c in db.execute(pcq).all(): + playable_counts[cls] = playable_counts.get(cls, 0) + c + # Year bounds — exclude the year filter. + by = db.execute( + wsq( + select(func.min(model.year), func.max(model.year)), + model, + ids, + {**p, "year_min": None, "year_max": None}, + kind_movie, + True, + ) + ).one() + if by[0] is not None: + years.append(by[0]) + if by[1] is not None: + years.append(by[1]) + # Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the + # rating filter and the UI badge use (IMDb preferred, audience fallback). + br = db.scalar( + wsq( + select(func.max(func.coalesce(model.imdb_rating, model.rating))), + model, + ids, + {**p, "rating_min": None}, + kind_movie, + True, + ) + ) + if br is not None: + ratings.append(float(br)) + # Duration bounds (movies) — exclude the duration filter. + if kind_movie: + bd = db.execute( + wsq( + select(func.min(model.duration_s), func.max(model.duration_s)), + model, + ids, + p, + kind_movie, + False, + ) + ).one() + if bd[0] is not None: + durs.append(bd[0]) + if bd[1] is not None: + durs.append(bd[1]) + + per_table(PlexItem, movie_ids, True) + per_table(PlexShow, show_ids, False) + # Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise + # be absent): the sidebar renders genres solely from this list and hides the whole genre section — + # chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to + # undo a zero-result selection. Count 0 is fine (genre chips don't display counts). + for g in split_csv(f.genres): + genre_counts.setdefault(g, 0) + # Same for the actively-selected studios, so a selected studio stays removable even at zero matches. + for s in split_csv(f.studios): + studio_counts.setdefault(s, 0) + # Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole + # section when its facet list is empty, which would trap a zero-result selection with no way to undo). + for c in split_csv(f.playable): + if c in PLAYBACK_CLASSES: + playable_counts.setdefault(c, 0) + # Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader. + alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))} + return { + "genres": [ + {"value": g, "count": c} + for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]]) + ], + "content_ratings": [ + {"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1]) + ], + "studios": [ + {"value": s, "count": c} + for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]])) + ], + # Fixed good→heavy order (not by count), so the class list reads as a severity scale. + "playable": [ + {"value": c, "count": playable_counts[c]} + for c in PLAYBACK_CLASSES + if c in playable_counts + ], + "year_min": min(years) if years else None, + "year_max": max(years) if years else None, + "rating_max": max(ratings) if ratings else None, + "duration_min": min(durs) if durs else None, + "duration_max": max(durs) if durs else None, + } diff --git a/backend/app/plex/queries.py b/backend/app/plex/queries.py new file mode 100644 index 00000000..b8d911f0 --- /dev/null +++ b/backend/app/plex/queries.py @@ -0,0 +1,382 @@ +"""Plex's reads for the HTTP layer: the mirrored rows a page shows, and a user's watch state on them. + +A user's own rows (`PlexState`) are read through `userscope`, so "someone else's row" is the same +answer as a missing one. A rating key with a NUL in it names no row (`lookups.by_rating_key`). +""" + +from typing import Literal + +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.collation import collated +from app.models import User +from app.plex import rollup +from app.plex.lookups import by_rating_key +from app.plex.models import ( + PlexCollection, + PlexItem, + PlexLibrary, + PlexPerson, + PlexSeason, + PlexShow, + PlexState, +) +from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys +from app.userscope import owned_by +from app.utils import like_escape + +# --- one row --------------------------------------------------------------------------------- + + +def item(db: Session, rating_key: str) -> PlexItem | None: + return by_rating_key(db, PlexItem, rating_key) + + +def movie(db: Session, rating_key: str) -> PlexItem | None: + """The movie with this key — an episode's key is not one.""" + it = item(db, rating_key) + return it if it is not None and it.kind == "movie" else None + + +def show(db: Session, rating_key: str) -> PlexShow | None: + return by_rating_key(db, PlexShow, rating_key) + + +def collection(db: Session, rating_key: str) -> PlexCollection | None: + return by_rating_key(db, PlexCollection, rating_key) + + +def show_by_id(db: Session, show_id: int) -> PlexShow | None: + return db.get(PlexShow, show_id) + + +def season_by_id(db: Session, season_id: int) -> PlexSeason | None: + return db.get(PlexSeason, season_id) + + +def library(db: Session, library_id: int) -> PlexLibrary | None: + return db.get(PlexLibrary, library_id) + + +def library_by_key(db: Session, plex_key: str) -> PlexLibrary | None: + return ( + db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(plex_key))) + .scalars() + .first() + ) + + +def library_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 + + +def person(db: Session, name: str) -> PlexPerson | None: + return db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first() + + +# --- a user's watch state -------------------------------------------------------------------- + + +def state(db: Session, user: User | int, it: PlexItem) -> PlexState | None: + return ( + db.execute( + select(PlexState).where(owned_by(PlexState.user_id, user), PlexState.item_id == it.id) + ) + .scalars() + .first() + ) + + +def states(db: Session, user: User | int, item_ids: list[int]) -> dict[int, PlexState]: + """The user's state on each of these items that has one, by item id.""" + if not item_ids: + return {} + return { + s.item_id: s + for s in db.execute( + select(PlexState).where( + owned_by(PlexState.user_id, user), PlexState.item_id.in_(item_ids) + ) + ).scalars() + } + + +# --- a show ---------------------------------------------------------------------------------- + + +def seasons(db: Session, sh: PlexShow) -> list[PlexSeason]: + return list( + db.execute( + select(PlexSeason) + .where(PlexSeason.show_id == sh.id) + .order_by(PlexSeason.season_number.asc().nullsfirst()) + ).scalars() + ) + + +def episodes(db: Session, sh: PlexShow) -> list[PlexItem]: + """A show's episodes in season/episode order.""" + return list( + 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() + ) + + +def episode_keys(db: Session, show_id: int) -> list[str]: + """The rating keys of a show's episodes, in the order `episodes` gives.""" + return list( + db.execute( + select(PlexItem.rating_key) + .where(PlexItem.show_id == show_id, PlexItem.kind == "episode") + .order_by( + PlexItem.season_number.asc().nullsfirst(), + PlexItem.episode_number.asc().nullsfirst(), + ) + ).scalars() + ) + + +def shows_by_keys(db: Session, rating_keys: list[str], exclude: int) -> dict[str, PlexShow]: + """The mirrored shows among these keys, but `exclude`, by key.""" + return { + s.rating_key: s + for s in db.execute( + select(PlexShow).where(PlexShow.rating_key.in_(rating_keys), PlexShow.id != exclude) + ).scalars() + } + + +# --- libraries ------------------------------------------------------------------------------- + + +def browsable_libraries(db: Session, lang: str) -> list[tuple[PlexLibrary, int]]: + """The mirrored libraries a user can browse, each with its title count (movies, or shows). An + HU overlay library is left out: it is never browsed, it only feeds its EN twin's Hungarian + metadata.""" + libs = db.execute( + select(PlexLibrary) + .where(PlexLibrary.overlay_of.is_(None)) + .order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang)) + ).scalars() + out = [] + for lib in libs: + if lib.kind == "movie": + count = db.scalar( + select(func.count(PlexItem.id)).where( + PlexItem.library_id == lib.id, PlexItem.kind == "movie" + ) + ) + else: + count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id)) + out.append((lib, count or 0)) + return out + + +# --- people ---------------------------------------------------------------------------------- + + +def person_photos(db: Session, names: list[str]) -> dict[str, str | None]: + return { + p.name: p.photo_thumb + for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(names))).scalars() + } + + +def cast_count(db: Session, name: str, movie_lib_ids: list[int], show_lib_ids: list[int]) -> int: + """How many movies (in `movie_lib_ids`) and shows (in `show_lib_ids`) name this person in their + cast. An empty id list leaves its side out.""" + count = 0 + if movie_lib_ids: + count += ( + db.scalar( + select(func.count(PlexItem.id)).where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_lib_ids), + PlexItem.cast_names.contains([name]), + ) + ) + or 0 + ) + if show_lib_ids: + count += ( + db.scalar( + select(func.count(PlexShow.id)).where( + PlexShow.library_id.in_(show_lib_ids), + PlexShow.cast_names.contains([name]), + ) + ) + or 0 + ) + return count + + +def filmography( + db: Session, + kind: Literal["movie", "show"], + name: str, + lib_ids: list[int], + lang: str, + limit: int, +) -> list: + """The movies or shows in these libraries whose cast or directors name this person, newest + first.""" + model: type[PlexItem] | type[PlexShow] = PlexItem if kind == "movie" else PlexShow + query = select(model).where( + model.library_id.in_(lib_ids), + or_(model.cast_names.contains([name]), model.directors.contains([name])), + ) + if model is PlexItem: + query = query.where(PlexItem.kind == "movie") + return list( + db.execute( + query.order_by( + model.year.desc().nullslast(), collated(title_col(model, lang), lang) + ).limit(limit) + ).scalars() + ) + + +# --- collections ----------------------------------------------------------------------------- + + +def collections_page( + db: Session, library: str | None, q: str | None, keys: list[str] | None, lang: str +) -> list[PlexCollection]: + """Collections, most-populated first: one library's (by its plex key), else every enabled + library's; name-filtered by `q`; narrowed to `keys` when given (an empty list asks for none).""" + if library is not None: + lib = library_by_key(db, library) + if lib is None: + return [] + query = select(PlexCollection).where(PlexCollection.library_id == lib.id) + else: + enabled_ids = list( + db.execute( + select(PlexLibrary.id).where( + PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) + ) + ).scalars() + ) + if not enabled_ids: + return [] + query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids)) + term = (q or "").strip() + if term: + query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%")) + if keys is not None: + # An explicit but empty `keys=` asks for nothing, which is not the same as no `keys` (every + # collection): answered here without a query. The empty IN () would also match nothing. + if not keys: + return [] + query = query.where(PlexCollection.rating_key.in_(keys)) + return list( + db.execute( + query.order_by( + PlexCollection.child_count.desc().nullslast(), collated(PlexCollection.title, lang) + ) + ).scalars() + ) + + +def library_collections(db: Session, library_id: int, lang: str) -> list[PlexCollection]: + return list( + db.execute( + select(PlexCollection) + .where(PlexCollection.library_id == library_id) + .order_by(collated(PlexCollection.title, lang)) + ).scalars() + ) + + +def collections_by_keys(db: Session, keys: list[str]) -> list[PlexCollection]: + return list( + db.execute(select(PlexCollection).where(PlexCollection.rating_key.in_(keys))).scalars() + ) + + +def collection_named(db: Session, library_id: int, title: str) -> PlexCollection | None: + """A collection of this library with this name, case aside.""" + return ( + db.execute( + select(PlexCollection).where( + PlexCollection.library_id == library_id, + func.lower(PlexCollection.title) == title.lower(), + ) + ) + .scalars() + .first() + ) + + +def collection_members( + db: Session, rating_key: str, lang: str +) -> tuple[list[PlexItem], list[PlexShow]]: + """A collection's movies and shows, each by year then title.""" + movies = db.execute( + select(PlexItem) + .where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key])) + .order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang)) + ).scalars() + shows = db.execute( + select(PlexShow) + .where(PlexShow.collection_keys.contains([rating_key])) + .order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang)) + ).scalars() + return list(movies), list(shows) + + +def library_movies_in( + db: Session, library_id: int, rating_key: str, lang: str, limit: int +) -> list[PlexItem]: + """The movies of one library in this collection, by year then title.""" + return list( + db.execute( + select(PlexItem) + .where( + PlexItem.library_id == library_id, + PlexItem.kind == "movie", + PlexItem.collection_keys.contains([rating_key]), + ) + .order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang)) + .limit(limit) + ).scalars() + ) + + +def show_rollups(db: Session, user_id: int, show_ids: list[int]) -> list: + """The user's episode rollup (total, watched, in progress, last resumed) of each of these shows + that has episodes.""" + if not show_ids: + return [] + return list( + db.execute( + rollup.agg_subquery(db, user_id, include_resumed=True).where( + PlexItem.show_id.in_(show_ids) + ) + ).all() + ) + + +def collection_tiles( + db: Session, keys: list[str] +) -> tuple[dict[str, list[str]], dict[str, list[str]], dict[str, int]]: + """What each collection's tile shows: the top two movie members' keys, the top two show members' + keys (a collection lives in one library, so exactly one side has any), and its movies' total + runtime (a show has no single duration).""" + movie_ids, show_ids = content_libs(db) + art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2) + art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2) + runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys) + return art_m, art_s, runtime diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 4882a629..487557b1 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -236,7 +236,7 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"): # The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`. # ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds` # filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software. -# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill). +# Public — it is the shared source of truth imported by plex.library (facet order + zero-fill). PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw") diff --git a/backend/app/plex/service.py b/backend/app/plex/service.py new file mode 100644 index 00000000..70e5154f --- /dev/null +++ b/backend/app/plex/service.py @@ -0,0 +1,81 @@ +"""Plex's writes for the HTTP layer, one function per use case. None of them commits: the handler +does, so a page that fails to save an enrichment still renders, and a Plex write and its local +mirror land together. + +The rest of the mirror's writes stay with the sync (`plex.sync`): a collection's targeted re-sync +after a write-back, its local cleanup after a delete. +""" + +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app import logs +from app.db import SessionLocal +from app.plex import detail as plex_detail +from app.plex import people as plex_people +from app.plex import tmdb as plex_tmdb +from app.plex.lookups import by_rating_key +from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexPerson, PlexShow + +log = logs.get_logger(__name__) + + +def enrich_show(db: Session, sh: PlexShow, meta: dict) -> None: + """Fill a not-yet-enriched show from its live metadata: the full cast (filterable), IMDb and + tagline, stamped so later views serve from the row.""" + plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta)) + plex_detail.apply_rich_detail(sh, meta) + sh.people_enriched_at = datetime.now(timezone.utc) + + +def enrich_movie(db: Session, it: PlexItem, meta: dict) -> None: + """The same for a movie. The listing sync stored only its top ~3 cast; the live metadata + carries all of them.""" + 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) + + +def set_collection_editable(col: PlexCollection, editable: bool) -> None: + col.editable = editable + + +def keep_created_collection( + db: Session, lib: PlexLibrary, rating_key: str, title: str, seed: str +) -> None: + """A collection Plex made, whose follow-up read failed: its row is kept, editable. The full sync + preserves `editable` but creates a missing row read-only, so the collection would drop out of + every "add to" list; and the duplicate-name check reads these rows, so a retry of the same name + would make a second one on Plex. Plex made it with exactly the seed in it, so the seed's row says + so too — otherwise the title showed unticked in the collection it was just added to, and the + collection empty, until that sync.""" + db.add( + PlexCollection( + rating_key=rating_key, library_id=lib.id, title=title, editable=True, child_count=1 + ) + ) + Model = PlexItem if lib.kind == "movie" else PlexShow + if row := by_rating_key(db, Model, seed): + row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rating_key])) + db.flush() + + +def enrich_person_bg(person_id: int) -> None: + """Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs + AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's + "no live TMDB call at serve time" invariant). The request's session is already closed by then, so + open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the + next open, and the bio lands from the row by then.""" + db = SessionLocal() + try: + person = db.get(PlexPerson, person_id) + if person is not None: + plex_tmdb.enrich_person(db, person) + except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user + db.rollback() + log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True) + finally: + db.close() diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index 301a4134..d6a4d723 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -1,18 +1,22 @@ """Shared helpers, serializers and constants for the plex route package.""" +from typing import TYPE_CHECKING + from sqlalchemy.orm import Session from app import logs -from app.plex.models import PlexItem +from app.plex import queries from app.plex.cards import norm_lang as _norm_lang # noqa: F401 — the package's historic name -from app.plex.lookups import by_rating_key from app.routes._common import not_found +if TYPE_CHECKING: + from app.plex.models import PlexItem + log = logs.get_logger(__name__) -def item_or_404(db: Session, rating_key: str) -> PlexItem: - it = by_rating_key(db, PlexItem, rating_key) +def item_or_404(db: Session, rating_key: str) -> "PlexItem": + it = queries.item(db, rating_key) if it is None: raise not_found("plex_item") return it diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index af9d70fe..b5917856 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -1,105 +1,30 @@ """Plex browse: the library list, the unified cross-library browse feed, and its faceted filters.""" from fastapi import APIRouter, Depends, Query -from sqlalchemy import ( - Boolean, - Integer, - String, - and_, - cast, - func, - literal, - null, - or_, - select, -) -from sqlalchemy.orm import Session, aliased +from sqlalchemy.orm import Session -from app.mailtext import lang_for_user -from app.collation import collated, sorted_words from app import sysconfig from app.auth import current_user from app.db import get_db +from app.mailtext import lang_for_user from app.models import User -from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState -from app.plex import rollup +from app.plex import library as plex_library +from app.plex import queries from app.plex.cards import display_title, episode_card -from app.plex.query import ( - FIELD_COLUMNS, - PLAYBACK_CLASSES, - TS_CONFIG, - LibraryFilters, - apply_meta_filters, - apply_movie_state_filter, - apply_show_state_filter, - content_libs, - field_recheck, - genres_col, - meta_filter_conds, - movie_status_case, - playable_conds, - playback_class_expr, - show_status_case, - split_csv, - summary_col, - title_col, - vector_col, - vector_match, -) +from app.plex.query import LibraryFilters from app.routes.plex._shared import _norm_lang -from app.utils import to_tsquery_str router = APIRouter(prefix="/api/plex", tags=["plex"]) -def sort_column(u, sort: str, lang: str = "en"): - """The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline - in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control - offers but this cannot order would quietly fall back to `added_at` and look like a broken control. - An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts - in the reader's alphabet (`collation`).""" - return { - "added": u.c.added_at, - "title": collated(u.c.title, lang), - "year": u.c.year, - "rating": u.c.rating, - "duration": u.c.duration_s, - "release": u.c.rel, - "lastwatched": u.c.last_watched, - }.get(sort, u.c.added_at) - - @router.get("/libraries") def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: """The mirrored libraries the user can browse (drives the Plex scope selector). HU overlay libraries are excluded — they're not browsable, they only feed the EN twin's Hungarian metadata.""" - libs = ( - db.execute( - select(PlexLibrary) - .where(PlexLibrary.overlay_of.is_(None)) - .order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang_for_user(user))) - ) - .scalars() - .all() - ) - out = [] - for lib in libs: - if lib.kind == "movie": - count = db.scalar( - select(func.count(PlexItem.id)).where( - PlexItem.library_id == lib.id, PlexItem.kind == "movie" - ) - ) - else: - count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id)) - out.append( - { - "key": lib.plex_key, - "title": lib.title, - "kind": lib.kind, - "count": count or 0, - } - ) + out = [ + {"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count} + for lib, count in queries.browsable_libraries(db, lang_for_user(user)) + ] return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out} @@ -124,193 +49,60 @@ def unified_library( episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section).""" offset = max(0, offset) lang = _norm_lang(lang) - p = f.as_dict() - movie_lib_ids, show_lib_ids = content_libs(db) - want_movies = scope in ("movie", "both") and bool(movie_lib_ids) - want_shows = scope in ("show", "both") and bool(show_lib_ids) - - ts = to_tsquery_str(q) if q else None - tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None - if field not in FIELD_COLUMNS and field != "all": - field = "all" - # `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for - # the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter. - grid_field = field != "episode" - recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None - - selects = [] - if want_movies and grid_field: - st = aliased(PlexState) - m_status = movie_status_case(st) - mq = ( - select( - PlexItem.rating_key.label("rk"), - literal("movie").label("kind"), - title_col(PlexItem, lang).label("title"), - PlexItem.year.label("year"), - PlexItem.rating.label("rating"), - # Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis. - func.left(summary_col(PlexItem, lang), 300).label("summary"), - PlexItem.added_at.label("added_at"), - PlexItem.originally_available_at.label("rel"), - PlexItem.duration_s.label("duration_s"), - PlexItem.playable.label("playable"), - PlexItem.transcode_hw.label("transcode_hw"), - cast(null(), Integer).label("season_count"), - func.coalesce(st.position_seconds, 0).label("position_seconds"), - m_status.label("status"), - literal(False).label("resumable"), - # "last watched" sort key: when the movie was last resumed, else when it was marked - # watched. NULL for never-touched movies → nullslast sinks them (see the sort dict). - func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"), - ( - func.ts_rank(vector_col(PlexItem, lang), tsq) - if tsq is not None - else literal(0.0) - ).label("rank"), - ) - .outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) - .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids)) - ) - mq = apply_movie_state_filter(mq, st, show) - mq = apply_meta_filters(mq, PlexItem, p, lang) - if f.duration_min is not None: - mq = mq.where(PlexItem.duration_s >= f.duration_min) - if f.duration_max is not None: - mq = mq.where(PlexItem.duration_s <= f.duration_max) - # Playback-class filter (movie-only — a show is an aggregate with no single class). - mq = mq.where(*playable_conds(split_csv(f.playable))) - if tsq is not None: - mq = mq.where(vector_match(PlexItem, lang, tsq)) - if recheck is not None: - mq = mq.where(recheck(PlexItem, field, tsq, lang)) - selects.append(mq) - if want_shows and grid_field: - agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery() - tot = func.coalesce(agg.c.total, 0) - wat = func.coalesce(agg.c.watched, 0) - inp = func.coalesce(agg.c.inprog, 0) - s_status = show_status_case(tot, wat, inp) - sq = ( - select( - PlexShow.rating_key.label("rk"), - literal("show").label("kind"), - title_col(PlexShow, lang).label("title"), - PlexShow.year.label("year"), - PlexShow.rating.label("rating"), - func.left(summary_col(PlexShow, lang), 300).label("summary"), - PlexShow.added_at.label("added_at"), - PlexShow.originally_available_at.label("rel"), - cast(null(), Integer).label("duration_s"), - cast(null(), String).label("playable"), - cast(null(), Boolean).label("transcode_hw"), - PlexShow.child_count.label("season_count"), - literal(0).label("position_seconds"), - s_status.label("status"), - # An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart). - (inp > 0).label("resumable"), - # Show leg's "last watched": max(resume/watched time) across the show's episodes (see - # rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast. - agg.c.resumed_at.label("last_watched"), - ( - func.ts_rank(vector_col(PlexShow, lang), tsq) - if tsq is not None - else literal(0.0) - ).label("rank"), - ) - .outerjoin(agg, agg.c.show_id == PlexShow.id) - .where(PlexShow.library_id.in_(show_lib_ids)) - ) - sq = apply_show_state_filter(sq, tot, wat, inp, show) - sq = apply_meta_filters(sq, PlexShow, p, lang) - if tsq is not None: - sq = sq.where(vector_match(PlexShow, lang, tsq)) - if recheck is not None: - sq = sq.where(recheck(PlexShow, field, tsq, lang)) - selects.append(sq) - + total, rows, eps = plex_library.library_page( + db, + user, + scope=scope, + q=q, + field=field, + sort=sort, + sort_dir=sort_dir, + show=show, + lang=lang, + offset=offset, + limit=limit, + f=f, + ) items = [] - total = 0 - if selects: - union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) - u = union.subquery() - total = db.scalar(select(func.count()).select_from(u)) or 0 - - if tsq is not None: - order = [u.c.rank.desc()] - else: - col = sort_column(u, sort, lang) - order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] - order.append(u.c.rk.desc()) - rows = db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all() - - for r in rows: - if r.kind == "movie": - items.append( - { - "id": r.rk, - "type": "movie", - "title": r.title, - "year": r.year, - "rating": r.rating, - "summary": r.summary, - "duration_seconds": r.duration_s, - "thumb": f"/api/plex/image/{r.rk}", - "playable": r.playable, - "transcode_hw": r.transcode_hw, - "status": r.status, - "position_seconds": r.position_seconds, - } - ) - else: - items.append( - { - "id": r.rk, - "type": "show", - "title": r.title, - "year": r.year, - "rating": r.rating, - "summary": r.summary, - "thumb": f"/api/plex/image/{r.rk}", - "season_count": r.season_count, - "status": r.status, - "resumable": r.resumable, - } - ) - - # Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour) - # and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes - # target the movie/show grid, so they suppress episodes. - episodes = [] - if tsq is not None and want_shows and field in ("all", "episode"): - ep_st = aliased(PlexState) - # In HU an episode is found by its Hungarian title too (the overlay fills it) — and - # still by its English one, like `vector_match` does for the grid. - if field == "episode": - ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, "")) - ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, "")) - else: - ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu - hit = ep_vec.op("@@")(tsq) - if lang == "hu": - hit = or_(ep_vec_hu.op("@@")(tsq), hit) - ep_vec = ep_vec_hu - eps = db.execute( - select(PlexItem, ep_st, PlexShow) - .outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id)) - .outerjoin(PlexShow, PlexShow.id == PlexItem.show_id) - .where( - PlexItem.kind == "episode", - PlexItem.library_id.in_(show_lib_ids), - hit, + for r in rows: + if r.kind == "movie": + items.append( + { + "id": r.rk, + "type": "movie", + "title": r.title, + "year": r.year, + "rating": r.rating, + "summary": r.summary, + "duration_seconds": r.duration_s, + "thumb": f"/api/plex/image/{r.rk}", + "playable": r.playable, + "transcode_hw": r.transcode_hw, + "status": r.status, + "position_seconds": r.position_seconds, + } ) - .order_by(func.ts_rank(ep_vec, tsq).desc()) - .limit(24) - ).all() - for e, s, sh in eps: - card = episode_card(e, s, lang=lang) - card["show_title"] = display_title(sh, lang) if sh is not None else None - episodes.append(card) + else: + items.append( + { + "id": r.rk, + "type": "show", + "title": r.title, + "year": r.year, + "rating": r.rating, + "summary": r.summary, + "thumb": f"/api/plex/image/{r.rk}", + "season_count": r.season_count, + "status": r.status, + "resumable": r.resumable, + } + ) + + episodes = [] + for e, s, sh in eps: + card = episode_card(e, s, lang=lang) + card["show_title"] = display_title(sh, lang) if sh is not None else None + episodes.append(card) return { "scope": scope, @@ -336,237 +128,4 @@ def facets( filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while a multi-select group still lets you add more. Merged across the scope's libraries; duration is movie-only. (Search text is not factored in — facets track the sidebar filters.)""" - empty = { - "genres": [], - "content_ratings": [], - "studios": [], - "playable": [], - "year_min": None, - "year_max": None, - "rating_max": None, - "duration_min": None, - "duration_max": None, - } - all_movie_ids, all_show_ids = content_libs(db) - movie_ids = all_movie_ids if scope in ("movie", "both") else [] - show_ids = all_show_ids if scope in ("show", "both") else [] - if not movie_ids and not show_ids: - return empty - lang = _norm_lang(lang) - p = f.as_dict() - - uid = user.id - - # The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a - # full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups - # (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call. - # The same filter on the same rows, so the counts do not change - # (test_plex_facets_grid_parity). - shows_in_scope: list[int] | None = None - if show_ids and show in ("watched", "in_progress", "unwatched"): - agg = rollup.agg_subquery(db, uid).subquery() - in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id) - in_scope = apply_show_state_filter( - in_scope, - func.coalesce(agg.c.total, 0), - func.coalesce(agg.c.watched, 0), - func.coalesce(agg.c.inprog, 0), - show, - ) - shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids)))) - - def wsq(query, model, ids, pp, kind_movie, with_dur): - """Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a - facet aggregation query, so the facets reflect exactly the visible result set.""" - query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang)) - if kind_movie: - query = query.where(model.kind == "movie") - if with_dur and pp.get("duration_min") is not None: - query = query.where(model.duration_s >= pp["duration_min"]) - if with_dur and pp.get("duration_max") is not None: - query = query.where(model.duration_s <= pp["duration_max"]) - # Playback-class filter (movie-only) — applied for the OTHER groups so an active class - # narrows them; the playable facet itself passes pp with playable=None (self-exclusion). - query = query.where(*playable_conds(split_csv(pp.get("playable")))) - # Join watch-state and reuse the grid's exact movie state filter (unified_library) — - # hidden movies never appear in the grid, so they must not inflate facet counts/bounds either. - st = aliased(PlexState) - query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid)) - query = apply_movie_state_filter(query, st, show) - elif shows_in_scope is not None: - query = query.where(model.id.in_(shows_in_scope)) - return query - - genre_counts: dict[str, int] = {} - cr_counts: dict[str, int] = {} - studio_counts: dict[str, int] = {} - playable_counts: dict[str, int] = {} - years: list[int] = [] - ratings: list[float] = [] - durs: list[int] = [] - - def per_table(model, ids, kind_movie: bool) -> None: - if not ids: - return - # Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid - # OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we - # offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND - # the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since - # every matching title carries them, so they stay togglable.) - genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None} - # Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON - # `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and - # jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") — - # so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the - # whole facets endpoint. Mirror the exact guard people.py already applies to the same columns. - # In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)). - gcol = genres_col(model, lang) - expanded = ( - wsq( - select(func.jsonb_array_elements_text(gcol).label("g")), - model, - ids, - genre_pp, - kind_movie, - True, - ) - .where(func.jsonb_typeof(gcol) == "array") - .subquery() - ) - for g, c in db.execute(select(expanded.c.g, func.count()).group_by(expanded.c.g)).all(): - genre_counts[g] = genre_counts.get(g, 0) + c - # Content ratings — exclude its own. - crq = ( - wsq( - select(model.content_rating, func.count()), - model, - ids, - {**p, "content_ratings": None}, - kind_movie, - True, - ) - .where(model.content_rating.isnot(None)) - .group_by(model.content_rating) - ) - for cr, c in db.execute(crq).all(): - cr_counts[cr] = cr_counts.get(cr, 0) + c - # Studios — exclude its own (each offered studio is a valid OR-addition that only widens). - stq = ( - wsq( - select(model.studio, func.count()), - model, - ids, - {**p, "studios": None}, - kind_movie, - True, - ) - .where(model.studio.isnot(None)) - .group_by(model.studio) - ) - for s, c in db.execute(stq).all(): - studio_counts[s] = studio_counts.get(s, 0) + c - # Playback class (movie-only) — exclude its own so each class stays an addable OR option. - if kind_movie: - pcls = playback_class_expr() - pcq = ( - wsq( - select(pcls.label("cls"), func.count()), - model, - ids, - {**p, "playable": None}, - kind_movie, - True, - ) - .where(pcls.isnot(None)) - .group_by(pcls) - ) - for cls, c in db.execute(pcq).all(): - playable_counts[cls] = playable_counts.get(cls, 0) + c - # Year bounds — exclude the year filter. - by = db.execute( - wsq( - select(func.min(model.year), func.max(model.year)), - model, - ids, - {**p, "year_min": None, "year_max": None}, - kind_movie, - True, - ) - ).first() - if by[0] is not None: - years.append(by[0]) - if by[1] is not None: - years.append(by[1]) - # Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the - # rating filter and the UI badge use (IMDb preferred, audience fallback). - br = db.scalar( - wsq( - select(func.max(func.coalesce(model.imdb_rating, model.rating))), - model, - ids, - {**p, "rating_min": None}, - kind_movie, - True, - ) - ) - if br is not None: - ratings.append(float(br)) - # Duration bounds (movies) — exclude the duration filter. - if kind_movie: - bd = db.execute( - wsq( - select(func.min(model.duration_s), func.max(model.duration_s)), - model, - ids, - p, - kind_movie, - False, - ) - ).first() - if bd[0] is not None: - durs.append(bd[0]) - if bd[1] is not None: - durs.append(bd[1]) - - per_table(PlexItem, movie_ids, True) - per_table(PlexShow, show_ids, False) - # Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise - # be absent): the sidebar renders genres solely from this list and hides the whole genre section — - # chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to - # undo a zero-result selection. Count 0 is fine (genre chips don't display counts). - for g in split_csv(f.genres): - genre_counts.setdefault(g, 0) - # Same for the actively-selected studios, so a selected studio stays removable even at zero matches. - for s in split_csv(f.studios): - studio_counts.setdefault(s, 0) - # Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole - # section when its facet list is empty, which would trap a zero-result selection with no way to undo). - for c in split_csv(f.playable): - if c in PLAYBACK_CLASSES: - playable_counts.setdefault(c, 0) - # Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader. - alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))} - return { - "genres": [ - {"value": g, "count": c} - for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]]) - ], - "content_ratings": [ - {"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1]) - ], - "studios": [ - {"value": s, "count": c} - for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]])) - ], - # Fixed good→heavy order (not by count), so the class list reads as a severity scale. - "playable": [ - {"value": c, "count": playable_counts[c]} - for c in PLAYBACK_CLASSES - if c in playable_counts - ], - "year_min": min(years) if years else None, - "year_max": max(years) if years else None, - "rating_max": max(ratings) if ratings else None, - "duration_min": min(durs) if durs else None, - "duration_max": max(durs) if durs else None, - } + return plex_library.facets(db, user, scope=scope, show=show, lang=lang, f=f) diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py index 799f8a04..9eff7632 100644 --- a/backend/app/routes/plex/collections.py +++ b/backend/app/routes/plex/collections.py @@ -1,90 +1,41 @@ """Plex collections: browse mirrored collections + the admin manual-collection editor.""" -from collections.abc import Callable -from datetime import date, datetime -from typing import Any, NamedTuple +from datetime import datetime +from typing import TYPE_CHECKING from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from sqlalchemy import func, select from sqlalchemy.orm import Session from app.mailtext import lang_for_user -from app.collation import collated, sorted_words +from app.collation import sorted_words from app import logs from app.auth import admin_user, current_user from app.db import get_db from app.models import User -from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState -from app.plex import rollup +from app.plex import queries, rollup, service from app.plex import sync as plex_sync from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured -from app.plex.lookups import by_rating_key -from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys -from app.utils import like_escape +from app.plex.collections import ( + DEFAULT_MEMBER_SORT, + Member, + collection_card, + collection_cert, + collection_editable, + collection_source, + sort_members, +) from app.refusal import refused from app.routes._common import not_found +if TYPE_CHECKING: + from app.plex.models import PlexCollection, PlexState + log = logs.get_logger(__name__) -# 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 - - router = APIRouter(prefix="/api/plex", tags=["plex"]) -_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) - } - @router.get("/collections") def collections( @@ -104,64 +55,22 @@ def collections( sidebar token and the applied-filter chip had nothing to render but the raw id. Resolving them through this endpoint costs one small row each; pulling the whole list to find two titles is ~200 KB.""" - if library is not None: - lib = ( - db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(library))) - .scalars() - .first() - ) - if lib is None: - return {"collections": []} - query = select(PlexCollection).where(PlexCollection.library_id == lib.id) - else: - enabled_ids = list( - db.execute( - select(PlexLibrary.id).where( - PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) - ) - ).scalars() - ) - if not enabled_ids: - return {"collections": []} - query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids)) - term = (q or "").strip() - if term: - query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%")) - if keys is not None: - wanted = [k for k in (keys or "").split(",") if k] - # An explicit but empty `keys=` asks for nothing — answer nothing, rather than falling through - # to "every collection" (which is what an unguarded empty IN () would have to become). - if not wanted: - return {"collections": []} - query = query.where(PlexCollection.rating_key.in_(wanted)) - rows = ( - db.execute( - query.order_by( - PlexCollection.child_count.desc().nullslast(), - collated(PlexCollection.title, lang_for_user(user)), - ) - ) - .scalars() - .all() - ) + wanted = [k for k in keys.split(",") if k] if keys is not None else None + rows = queries.collections_page(db, library, q, wanted, lang_for_user(user)) # A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW # members (a collection lives in one library, so exactly one side has art) — a show collection would # otherwise render blank; the collection's own poster is the next fallback, then the colour hash. # Runtime is movie-only (shows have no single duration). - movie_ids, show_ids = content_libs(db) - keys = [c.rating_key for c in rows] - art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2) - art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2) - runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys) + art_m, art_s, runtime = queries.collection_tiles(db, [c.rating_key for c in rows]) - def _art(c: PlexCollection) -> list[str]: + def _art(c: "PlexCollection") -> list[str]: member = art_m.get(c.rating_key) or art_s.get(c.rating_key) return member or ([c.rating_key] if c.thumb_key else []) return { "collections": [ { - **_collection_card(c), + **collection_card(c), "art": _art(c), "total_runtime_s": runtime.get(c.rating_key, 0), } @@ -179,89 +88,21 @@ def collections_for_item( """What a movie or show card's "Add to collection" lists: the editable collections of the title's library (a collection lives in one library, and only a manual one can take a title — the same `can_edit` the editor offers), each saying whether the title is in it already, by name.""" - movie = by_rating_key(db, PlexItem, item_rating_key) - title: PlexItem | PlexShow | None = ( - movie if movie is not None and movie.kind == "movie" else None - ) or by_rating_key(db, PlexShow, item_rating_key) + movie = queries.movie(db, item_rating_key) + title = movie or queries.show(db, item_rating_key) if title is None: raise not_found("item") member_of = set(title.collection_keys or []) - rows = ( - db.execute( - select(PlexCollection) - .where(PlexCollection.library_id == title.library_id) - .order_by(collated(PlexCollection.title, lang_for_user(user))) - ) - .scalars() - .all() - ) + rows = queries.library_collections(db, title.library_id, lang_for_user(user)) return { "collections": [ - {**_collection_card(c), "contains": c.rating_key in member_of} + {**collection_card(c), "contains": c.rating_key in member_of} for c in rows - if _collection_editable(c) + if collection_editable(c) ] } -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 - - @router.get("/collections/{rating_key}") def collection_detail( rating_key: str, @@ -277,45 +118,20 @@ def collection_detail( what this page did before it had a control. Read-only here; edits go through the admin mutation endpoints below.""" lang = norm_lang(lang) - col = by_rating_key(db, PlexCollection, rating_key) + col = queries.collection(db, rating_key) if col is None: raise not_found("collection") - movies = ( - db.execute( - select(PlexItem) - .where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key])) - .order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang)) - ) - .scalars() - .all() - ) - shows = ( - db.execute( - select(PlexShow) - .where(PlexShow.collection_keys.contains([rating_key])) - .order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang)) - ) - .scalars() - .all() - ) + movies, shows = queries.collection_members(db, rating_key, lang) mstate = rollup.item_state_map(db, user.id, [m.id for m in movies]) # ONE rollup pass over the show members, for both the aggregate badge status and `resumed_at` — # the browse grid's "last watched" for a show (the newest resume/watched across its episodes). The # cheaper `rollup.state_map` gives only the status, which would leave `lastwatched` unorderable on # a show collection while the control still offered it. - show_rows = ( - db.execute( - rollup.agg_subquery(db, user.id, include_resumed=True).where( - PlexItem.show_id.in_([s.id for s in shows]) - ) - ).all() - if shows - else [] - ) + show_rows = queries.show_rollups(db, user.id, [s.id for s in shows]) sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows} sresumed = {r.show_id: r.resumed_at for r in show_rows} - def _movie_last_watched(st: PlexState | None) -> datetime | None: + def _movie_last_watched(st: "PlexState | None") -> datetime | None: # Same definition as the browse grid's: when it was last resumed, else when it was marked # watched (coalesce(progress_updated_at, watched_at)). return (st.progress_updated_at or st.watched_at) if st else None @@ -359,8 +175,8 @@ def collection_detail( years = [m.year for m in members if m.year is not None] certs = [m.content_rating for m in movies] + [s.content_rating for s in shows] return { - **_collection_card(col), - "content_rating": _collection_cert(certs), + **collection_card(col), + "content_rating": collection_cert(certs), "year_min": min(years) if years else None, "year_max": max(years) if years else None, "total_runtime_s": sum(m.duration_s or 0 for m in movies), @@ -368,11 +184,11 @@ def collection_detail( } -def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: - col = by_rating_key(db, PlexCollection, rating_key) +def _editable_collection_or_403(db: Session, rating_key: str) -> "PlexCollection": + col = queries.collection(db, rating_key) if col is None: raise not_found("collection") - if not _collection_editable(col): + if not collection_editable(col): raise refused(403, "collection_read_only") return col @@ -383,16 +199,7 @@ def _reject_duplicate_name( """No two collections in a section may share a name: Plex CONFLATES same-named collections' membership (both rating_keys resolve to one merged member list — a real footgun), so forbid it up front on create + rename rather than mirror the mess.""" - dup = ( - db.execute( - select(PlexCollection).where( - PlexCollection.library_id == library_id, - func.lower(PlexCollection.title) == title.lower(), - ) - ) - .scalars() - .first() - ) + dup = queries.collection_named(db, library_id, title) if dup is not None and dup.rating_key != exclude_rk: # Structured, so the bilingual UI says it in the reader's language (it was toasted in # Hungarian beside an English error dialog saying the same thing). @@ -435,15 +242,13 @@ def create_collection( seed = body.item_rating_key if not title or not seed: raise refused(400, "collection_fields_required") - seed_show = by_rating_key(db, PlexShow, seed) + seed_show = queries.show(db, seed) if library: - lib = ( - db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first() - ) + lib = queries.library_by_key(db, library) else: # The "New collection" flow only knows the seed title → derive its library. - seed_item = by_rating_key(db, PlexItem, seed) or seed_show - lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None + seed_item = queries.item(db, seed) or seed_show + lib = queries.library(db, seed_item.library_id) if seed_item else None if lib is None: raise not_found("library") _reject_duplicate_name(db, lib.id, title) @@ -456,28 +261,16 @@ def create_collection( wrote = True new_rk = str(new.get("ratingKey") or "") col = plex_sync.resync_collection(db, plex, lib, new_rk) - col.editable = True + service.set_collection_editable(col, True) db.commit() except (PlexError, PlexNotConfigured) as e: db.rollback() if wrote and new_rk: - # It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the - # full sync preserves `editable` but creates a missing row read-only, so the collection - # would drop out of every "add to" list; and the duplicate-name check reads these rows, - # so a retry of the same name would make a second one on Plex. Plex made it with exactly - # the seed in it, so the seed's row says so too — otherwise the title showed unticked in - # the collection it was just added to, and the collection empty, until that sync. - db.add( - PlexCollection( - rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1 - ) - ) - Model = PlexItem if lib.kind == "movie" else PlexShow - if row := by_rating_key(db, Model, seed): - row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk])) + # It is on Plex now, and only the follow-up read failed: keep its row (see the service). + service.keep_created_collection(db, lib, new_rk, title, seed) db.commit() raise _write_or_pending_error(wrote, e) from e - return _collection_card(col) + return collection_card(col) @router.post("/collections/{rating_key}/items/{item_rating_key}") @@ -488,7 +281,7 @@ def collection_add_item( db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) - lib = db.get(PlexLibrary, col.library_id) + lib = queries.library(db, col.library_id) wrote = False try: with PlexClient(db) as plex: @@ -499,7 +292,7 @@ def collection_add_item( except (PlexError, PlexNotConfigured) as e: db.rollback() raise _write_or_pending_error(wrote, e) from e - return _collection_card(col) + return collection_card(col) @router.delete("/collections/{rating_key}/items/{item_rating_key}") @@ -510,7 +303,7 @@ def collection_remove_item( db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) - lib = db.get(PlexLibrary, col.library_id) + lib = queries.library(db, col.library_id) wrote = False try: with PlexClient(db) as plex: @@ -521,7 +314,7 @@ def collection_remove_item( except (PlexError, PlexNotConfigured) as e: db.rollback() raise _write_or_pending_error(wrote, e) from e - return _collection_card(col) + return collection_card(col) class CollectionRenameIn(BaseModel): @@ -540,7 +333,7 @@ def collection_rename( if not title: raise refused(400, "title_required") _reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key) - lib = db.get(PlexLibrary, col.library_id) + lib = queries.library(db, col.library_id) wrote = False try: with PlexClient(db) as plex: @@ -551,7 +344,7 @@ def collection_rename( except (PlexError, PlexNotConfigured) as e: db.rollback() raise _write_or_pending_error(wrote, e) from e - return _collection_card(col) + return collection_card(col) @router.delete("/collections/{rating_key}") @@ -559,7 +352,7 @@ def collection_delete( rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) ) -> dict: col = _editable_collection_or_403(db, rating_key) - lib = db.get(PlexLibrary, col.library_id) + lib = queries.library(db, col.library_id) try: with PlexClient(db) as plex: plex.delete_collection(rating_key) @@ -584,12 +377,12 @@ def collection_set_editable( ) -> dict: """Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections (IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex.""" - col = by_rating_key(db, PlexCollection, rating_key) + col = queries.collection(db, rating_key) if col is None: raise not_found("collection") want = body.editable - if want and (col.smart or _collection_source(col) != "collection"): + if want and (col.smart or collection_source(col) != "collection"): raise refused(400, "collection_smart") - col.editable = want + service.set_collection_editable(col, want) db.commit() - return _collection_card(col) + return collection_card(col) diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index 1df6db3b..5aa1b0e0 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -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"), diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py index 2d59f5a2..6971d28c 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -4,22 +4,18 @@ import threading import time from fastapi import APIRouter, BackgroundTasks, Depends -from sqlalchemy import func, or_, select from sqlalchemy.orm import Session -from app.collation import collated -from app import logs, sysconfig +from app import sysconfig from app.auth import current_user -from app.db import SessionLocal, get_db +from app.db import get_db from app.models import User -from app.plex.models import PlexItem, PlexPerson, PlexShow -from app.plex import rollup +from app.plex import queries, rollup, service from app.plex import tmdb as plex_tmdb from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card -from app.plex.query import content_libs, split_csv, title_col +from app.plex.query import content_libs, split_csv router = APIRouter(prefix="/api/plex", tags=["plex"]) -log = logs.get_logger(__name__) # How many "Known for" titles the person page shows (the stored pool is deeper; see plex.tmdb). @@ -76,35 +72,13 @@ def people_cards( movie_lib_ids, show_lib_ids = content_libs(db) want_movies = scope in ("movie", "both") and bool(movie_lib_ids) want_shows = scope in ("show", "both") and bool(show_lib_ids) - photos = { - p.name: p.photo_thumb - for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(wanted))).scalars() - } - out = [] - for nm in wanted: - count = 0 - if want_movies: - count += ( - db.scalar( - select(func.count(PlexItem.id)).where( - PlexItem.kind == "movie", - PlexItem.library_id.in_(movie_lib_ids), - PlexItem.cast_names.contains([nm]), - ) - ) - or 0 - ) - if want_shows: - count += ( - db.scalar( - select(func.count(PlexShow.id)).where( - PlexShow.library_id.in_(show_lib_ids), - PlexShow.cast_names.contains([nm]), - ) - ) - or 0 - ) - out.append({"name": nm, "photo": photos.get(nm), "count": count}) + photos = queries.person_photos(db, wanted) + movies = movie_lib_ids if want_movies else [] + shows = show_lib_ids if want_shows else [] + out = [ + {"name": nm, "photo": photos.get(nm), "count": queries.cast_count(db, nm, movies, shows)} + for nm in wanted + ] return {"people": out} @@ -140,24 +114,6 @@ def _should_schedule_enrich(person_id: int) -> bool: return True -def _enrich_person_bg(person_id: int) -> None: - """Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs - AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's - "no live TMDB call at serve time" invariant). The request's session is already closed by then, so - open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the - next open, and the bio lands from the row by then.""" - db = SessionLocal() - try: - person = db.get(PlexPerson, person_id) - if person is not None: - plex_tmdb.enrich_person(db, person) - except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user - db.rollback() - log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True) - finally: - db.close() - - @router.get("/person/{name}") def person_detail( name: str, @@ -179,7 +135,7 @@ def person_detail( fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet. """ lang = norm_lang(lang) - person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first() + person = queries.person(db, name) # Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path. # Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded) # can't pile background tasks onto the shared threadpool. @@ -193,53 +149,18 @@ def person_detail( and bool(sysconfig.get_str(db, "tmdb_api_key")) ) if enriching and person is not None and _should_schedule_enrich(person.id): - background_tasks.add_task(_enrich_person_bg, person.id) + background_tasks.add_task(service.enrich_person_bg, person.id) movie_lib_ids, show_lib_ids = content_libs(db) movies: list[dict] = [] if movie_lib_ids: - items = ( - db.execute( - select(PlexItem) - .where( - PlexItem.kind == "movie", - PlexItem.library_id.in_(movie_lib_ids), - or_( - PlexItem.cast_names.contains([name]), - PlexItem.directors.contains([name]), - ), - ) - .order_by( - PlexItem.year.desc().nullslast(), collated(title_col(PlexItem, lang), lang) - ) - .limit(_PERSON_SECTION_MAX) - ) - .scalars() - .all() - ) + items = queries.filmography(db, "movie", name, movie_lib_ids, lang, _PERSON_SECTION_MAX) states = rollup.item_state_map(db, user.id, [it.id for it in items]) movies = [movie_card(it, states.get(it.id), lang=lang) for it in items] shows: list[dict] = [] if show_lib_ids: - show_rows = ( - db.execute( - select(PlexShow) - .where( - PlexShow.library_id.in_(show_lib_ids), - or_( - PlexShow.cast_names.contains([name]), - PlexShow.directors.contains([name]), - ), - ) - .order_by( - PlexShow.year.desc().nullslast(), collated(title_col(PlexShow, lang), lang) - ) - .limit(_PERSON_SECTION_MAX) - ) - .scalars() - .all() - ) + show_rows = queries.filmography(db, "show", name, show_lib_ids, lang, _PERSON_SECTION_MAX) statuses = rollup.state_map(db, user.id, [s.id for s in show_rows]) shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows] diff --git a/backend/tests/test_plex_collection_sort.py b/backend/tests/test_plex_collection_sort.py index 4326e4e8..7eeb09df 100644 --- a/backend/tests/test_plex_collection_sort.py +++ b/backend/tests/test_plex_collection_sort.py @@ -20,8 +20,8 @@ from app.main import app from app.models import User from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState from app.plex.query import PLEX_SORT_KEYS -from app.routes.plex.browse import sort_column -from app.routes.plex.collections import ( +from app.plex.library import sort_column +from app.plex.collections import ( DEFAULT_MEMBER_SORT, MEMBER_SORT_KEYS, Member, diff --git a/backend/tests/test_plex_people_enrich.py b/backend/tests/test_plex_people_enrich.py index 34fa6db3..c8e650a8 100644 --- a/backend/tests/test_plex_people_enrich.py +++ b/backend/tests/test_plex_people_enrich.py @@ -362,7 +362,7 @@ def test_the_person_page_says_a_bio_is_on_its_way(db, monkeypatch, override_dep) monkeypatch.setattr(state, "_configured_cache", True) override_dep(get_db, db.info["request"]) override_dep(current_user, lambda: user) - monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: None) + monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: None) key = {"value": "key"} monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: key["value"]) diff --git a/backend/tests/test_plex_person_bio.py b/backend/tests/test_plex_person_bio.py index 40ca623c..c2c1cf09 100644 --- a/backend/tests/test_plex_person_bio.py +++ b/backend/tests/test_plex_person_bio.py @@ -191,7 +191,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, overri db.commit() scheduled: list[int] = [] - monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) + monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: scheduled.append(pid)) monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: "key") people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open @@ -204,7 +204,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, overri def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch): # The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling # back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed). - from app.routes.plex import people + from app.plex import service calls = {"rollback": 0, "close": 0, "enriched": 0} @@ -222,13 +222,13 @@ def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch): calls["close"] += 1 # Success path: person found → enrich called, session closed, no rollback. - monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object())) + monkeypatch.setattr(service, "SessionLocal", lambda: _FakeSession(object())) monkeypatch.setattr( - people.plex_tmdb, + service.plex_tmdb, "enrich_person", lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1), ) - people._enrich_person_bg(5) + service.enrich_person_bg(5) assert calls == {"rollback": 0, "close": 1, "enriched": 1} # Error path: enrich raises → rollback + close, no exception escapes. @@ -237,14 +237,14 @@ def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch): def _boom(db, p): raise RuntimeError("enrich blew up") - monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom) - people._enrich_person_bg(5) # must not raise + monkeypatch.setattr(service.plex_tmdb, "enrich_person", _boom) + service.enrich_person_bg(5) # must not raise assert calls["rollback"] == 1 and calls["close"] == 1 # Missing person (deleted id): None-guard skips enrich, session still closed. calls.update({"rollback": 0, "close": 0, "enriched": 0}) - monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None)) - people._enrich_person_bg(9) + monkeypatch.setattr(service, "SessionLocal", lambda: _FakeSession(None)) + service.enrich_person_bg(9) assert calls == {"rollback": 0, "close": 1, "enriched": 0} @@ -281,7 +281,7 @@ def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch, override db.commit() scheduled: list[int] = [] - monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) + monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: scheduled.append(pid)) resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Done P") assert resp.status_code == 200, resp.text diff --git a/backend/tests/test_plex_service.py b/backend/tests/test_plex_service.py new file mode 100644 index 00000000..df28d037 --- /dev/null +++ b/backend/tests/test_plex_service.py @@ -0,0 +1,264 @@ +"""Plex's service and queries below the HTTP layer, one use case each. + +The routes are thin callers now, and their own tests stay the contract a client sees. These hold +what each read and write does on its own: that a user's watch state is theirs alone (another +user's state is the same answer as none), that a write leaves the commit to its caller, and the rule +each one carries (an explicit empty key list asks for nothing, a kept collection is editable and +holds its seed). DB-backed (real Postgres). +""" + +from datetime import datetime, timezone + +from sqlalchemy import select + +from app.plex import library, queries, service +from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState +from app.plex.query import LibraryFilters + +NOW = datetime(2024, 6, 1, tzinfo=timezone.utc) + + +def _lib(db, key="1", kind="movie", **kw): + lib = PlexLibrary(plex_key=key, title=kw.pop("title", f"L{key}"), kind=kind, enabled=True, **kw) + db.add(lib) + db.commit() + return lib + + +def _movie(db, lib, rk, **kw): + it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=kw.pop("title", rk), **kw) + db.add(it) + db.commit() + return it + + +def _state(db, user, it, **kw): + st = PlexState(user_id=user.id, item_id=it.id, **kw) + db.add(st) + db.commit() + return st + + +def _uncommitted(db): + return bool(db.new or db.dirty or db.deleted) or db.in_transaction() + + +def _page(db, user, **kw): + args = dict( + scope="both", + q=None, + field="all", + sort="title", + sort_dir="asc", + show="all", + lang="en", + offset=0, + limit=40, + f=LibraryFilters(), + ) + args.update(kw) + return library.library_page(db, user, **args) + + +# --- whose a watch state is ---------------------------------------------------------------- + + +def test_a_users_watch_state_is_theirs_alone(db, make_user): + owner, stranger = make_user(), make_user() + lib = _lib(db) + it = _movie(db, lib, "m1") + _state(db, owner, it, status="watched") + assert queries.state(db, owner, it).status == "watched" + assert queries.state(db, stranger, it) is None + assert queries.states(db, stranger, [it.id]) == {} + assert set(queries.states(db, owner, [it.id])) == {it.id} + assert queries.states(db, owner, []) == {} + + +def test_the_grid_shows_the_callers_state_and_hides_only_what_they_hid(db, make_user): + me, other = make_user(), make_user() + lib = _lib(db) + seen = _movie(db, lib, "seen") + hid = _movie(db, lib, "hid") + _state(db, other, seen, status="watched") + _state(db, other, hid, status="hidden") + _state(db, me, seen, status="new", position_seconds=90) + total, rows, _ = _page(db, me) + assert total == 2 + assert {r.rk: (r.status, r.position_seconds) for r in rows}["seen"][1] == 90 + assert [r.rk for r in _page(db, me, show="watched")[1]] == [] + assert [r.rk for r in _page(db, other, show="watched")[1]] == ["seen"] + assert [r.rk for r in _page(db, other)[1]] == ["seen"] # their hidden one is gone, for them + + +def test_the_facets_count_what_the_caller_would_see(db, make_user): + me, other = make_user(), make_user() + lib = _lib(db) + _movie(db, lib, "a", genres=["Drama"], year=2001) + hid = _movie(db, lib, "b", genres=["Horror"], year=1990) + _state(db, other, hid, status="hidden") + mine = library.facets(db, me, scope="both", show="all", lang="en", f=LibraryFilters()) + theirs = library.facets(db, other, scope="both", show="all", lang="en", f=LibraryFilters()) + assert [g["value"] for g in mine["genres"]] == ["Drama", "Horror"] + assert [g["value"] for g in theirs["genres"]] == ["Drama"] + assert (theirs["year_min"], mine["year_min"]) == (2001, 1990) + + +def test_an_episode_search_joins_the_callers_state(db, make_user): + me, other = make_user(), make_user() + lib = _lib(db, "2", kind="show") + sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show") + db.add(sh) + db.commit() + ep = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=sh.id) + db.add(ep) + db.commit() + _state(db, other, ep, status="watched") + _, _, eps = _page(db, me, q="pilot") + assert [(e.rating_key, s, show.rating_key) for e, s, show in eps] == [("e1", None, "s1")] + + +def test_a_shows_rollup_is_the_callers(db, make_user): + me, other = make_user(), make_user() + lib = _lib(db, "2", kind="show") + sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show") + db.add(sh) + db.commit() + ep = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="E", show_id=sh.id) + db.add(ep) + db.commit() + _state(db, other, ep, status="watched", watched_at=NOW) + (row,) = queries.show_rollups(db, me.id, [sh.id]) + assert (row.total, row.watched, row.resumed_at) == (1, 0, None) + assert queries.show_rollups(db, me.id, []) == [] + + +# --- libraries, people, a show --------------------------------------------------------------- + + +def test_the_browsable_libraries_leave_an_overlay_out_and_count_their_titles(db): + en = _lib(db, "1", title="Movies") + _lib(db, "9", title="Filmek", overlay_of=en.id) + _movie(db, en, "m1") + _movie(db, en, "m2") + db.add(PlexItem(rating_key="x", library_id=en.id, kind="episode", title="not a movie")) + db.commit() + assert [(lib.plex_key, n) for lib, n in queries.browsable_libraries(db, "en")] == [("1", 2)] + + +def test_a_filmography_is_cast_or_directors_and_movies_only(db): + lib = _lib(db) + _movie(db, lib, "acted", cast_names=["Ann"], year=2000) + _movie(db, lib, "directed", directors=["Ann"], year=2010) + _movie(db, lib, "other", cast_names=["Bob"]) + db.add( + PlexItem(rating_key="ep", library_id=lib.id, kind="episode", title="E", cast_names=["Ann"]) + ) + db.commit() + rows = queries.filmography(db, "movie", "Ann", [lib.id], "en", limit=10) + assert [r.rating_key for r in rows] == ["directed", "acted"] + assert queries.filmography(db, "show", "Ann", [lib.id], "en", limit=10) == [] + assert queries.cast_count(db, "Ann", [lib.id], []) == 1 # the director credit is not cast + assert queries.cast_count(db, "Ann", [], []) == 0 + + +def test_a_shows_episodes_and_their_keys_are_in_season_order(db): + lib = _lib(db, "2", kind="show") + sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show") + db.add(sh) + db.commit() + for rk, season, ep in (("b", 2, 1), ("a", 1, 2), ("c", 1, 1)): + db.add( + PlexItem( + rating_key=rk, + library_id=lib.id, + kind="episode", + title=rk, + show_id=sh.id, + season_number=season, + episode_number=ep, + ) + ) + db.commit() + assert [e.rating_key for e in queries.episodes(db, sh)] == ["c", "a", "b"] + assert queries.episode_keys(db, sh.id) == ["c", "a", "b"] + assert queries.show(db, "s1\x00") is None # a NUL names no row + + +# --- collections ----------------------------------------------------------------------------- + + +def test_a_collection_list_narrows_by_library_name_and_keys(db): + lib = _lib(db) + off = _lib(db, "3", title="Off") + off.enabled = False + db.add_all( + [ + PlexCollection(rating_key="c1", library_id=lib.id, title="Bond 100%", child_count=5), + PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=9), + PlexCollection(rating_key="c3", library_id=off.id, title="Hidden"), + ] + ) + db.commit() + keys = lambda rows: [c.rating_key for c in rows] # noqa: E731 + assert keys(queries.collections_page(db, None, None, None, "en")) == ["c2", "c1"] + assert keys(queries.collections_page(db, "3", None, None, "en")) == ["c3"] + assert keys(queries.collections_page(db, "404", None, None, "en")) == [] + assert keys(queries.collections_page(db, None, "0%", None, "en")) == ["c1"] # % is literal + assert keys(queries.collections_page(db, None, "%", None, "en")) == ["c1"] + assert keys(queries.collections_page(db, None, None, ["c1"], "en")) == ["c1"] + assert keys(queries.collections_page(db, None, None, [], "en")) == [] # asks for none + assert queries.collection_named(db, lib.id, "PIXAR").rating_key == "c2" + assert queries.collection_named(db, off.id, "pixar") is None + + +def test_a_strip_lists_one_librarys_members_of_a_collection(db): + lib, other = _lib(db), _lib(db, "4") + _movie(db, lib, "in", collection_keys=["c1"], year=2002) + _movie(db, lib, "early", collection_keys=["c1"], year=1999) + _movie(db, other, "elsewhere", collection_keys=["c1"]) + _movie(db, lib, "out", collection_keys=["c2"]) + rows = queries.library_movies_in(db, lib.id, "c1", "en", limit=10) + assert [r.rating_key for r in rows] == ["early", "in"] + movies, shows = queries.collection_members(db, "c1", "en") + assert ([m.rating_key for m in movies], shows) == (["early", "in", "elsewhere"], []) + + +# --- writes ---------------------------------------------------------------------------------- + + +def test_a_movie_enrichment_is_stamped_and_left_to_the_caller_to_commit(db): + lib = _lib(db) + it = _movie(db, lib, "m1") + meta = {"Role": [{"tag": "Ann", "role": "Lead"}], "tagline": "Big."} + service.enrich_movie(db, it, meta) + assert it.people_enriched_at is not None + assert "Ann" in (it.cast_names or []) + assert _uncommitted(db) + db.rollback() + db.expire_all() + assert db.get(PlexItem, it.id).people_enriched_at is None + + +def test_a_collection_kept_after_a_failed_read_is_editable_and_holds_its_seed(db): + lib = _lib(db) + seed = _movie(db, lib, "seed", collection_keys=["old"]) + service.keep_created_collection(db, lib, "new", "Mine", "seed") + service.keep_created_collection(db, lib, "new2", "Mine 2", "seed") + db.commit() + db.expire_all() + col = queries.collection(db, "new") + assert (col.editable, col.child_count, col.title) == (True, 1, "Mine") + assert db.get(PlexItem, seed.id).collection_keys == ["old", "new", "new2"] + + +def test_marking_a_collection_editable_is_left_to_the_caller(db): + lib = _lib(db) + db.add(PlexCollection(rating_key="c1", library_id=lib.id, title="C")) + db.commit() + col = queries.collection(db, "c1") + service.set_collection_editable(col, True) + assert _uncommitted(db) + db.commit() + db.expire_all() + assert db.scalar(select(PlexCollection.editable)) is True diff --git a/backend/tests/test_route_conventions.py b/backend/tests/test_route_conventions.py index 59b4bb7d..e60e7d51 100644 --- a/backend/tests/test_route_conventions.py +++ b/backend/tests/test_route_conventions.py @@ -40,7 +40,7 @@ FUNCTIONS = (ast.FunctionDef, ast.AsyncFunctionDef) # of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`. SERIALIZERS = { "JSONResponse", - "_collection_card", + "collection_card", "_playlist_card", "_serialize", "_serialize_folder", diff --git a/backend/tests/test_unit_fences.py b/backend/tests/test_unit_fences.py index 125b3b1b..37f4755c 100644 --- a/backend/tests/test_unit_fences.py +++ b/backend/tests/test_unit_fences.py @@ -110,13 +110,8 @@ UNIT_ROUTES = { # What each HTTP file still does, by file: the debt a unit's sprint pays off. Exact counts. A core # route's count is only the unit tables it imports; it is paid by the sprint of the unit it reaches. ALLOWED = { - "plex/_shared.py": 1, "plex/admin.py": 12, - "plex/browse.py": 93, - "plex/collections.py": 33, - "plex/detail.py": 29, "plex/discover.py": 44, - "plex/people.py": 20, "plex/playback.py": 42, "plex/playlists.py": 80, } diff --git a/docs/gates.md b/docs/gates.md index 88f0e772..d6990113 100644 --- a/docs/gates.md +++ b/docs/gates.md @@ -113,7 +113,7 @@ project checks needs a row. | CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) | | npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 | | knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry | -| unit fence | the database work the HTTP layer still does, pinned | `backend/tests/test_unit_fences.py`: a unit's route file may not build SQL, call the session's data methods, set a model's attributes or import a unit's tables; a core route may not import a unit's tables. Switched on unit by unit: `ALLOWED` pins each file's count exactly, so a rise fails and a fall fails until the pin is lowered — the list may only shrink. End state: empty, and the pin mechanism goes. A unit in `FENCED` is held below its HTTP layer too: its code reads another unit's tables only along `READS`, writes them never, filters owners through `userscope`, and commits only where `UNIT_COMMITS` names a job's own session. | 826 in 23 files (E42 S0) → 667 in 19 (S1: Capture 0, fenced) → 525 in 14 (S2: Reader 0, fenced) → 354 in 9 (S3: Catalog 0, fenced; core routes 0) | +| unit fence | the database work the HTTP layer still does, pinned | `backend/tests/test_unit_fences.py`: a unit's route file may not build SQL, call the session's data methods, set a model's attributes or import a unit's tables; a core route may not import a unit's tables. Switched on unit by unit: `ALLOWED` pins each file's count exactly, so a rise fails and a fall fails until the pin is lowered — the list may only shrink. End state: empty, and the pin mechanism goes. A unit in `FENCED` is held below its HTTP layer too: its code reads another unit's tables only along `READS`, writes them never, filters owners through `userscope`, and commits only where `UNIT_COMMITS` names a job's own session. | 826 in 23 files (E42 S0) → 667 in 19 (S1: Capture 0, fenced) → 525 in 14 (S2: Reader 0, fenced) → 354 in 9 (S3: Catalog 0, fenced; core routes 0) → 178 in 4 (S4a: Plex browse, detail, people, collections 0) | | React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) | ## Accepted findings