From e7b5b8e87f5d2d39dd598a38156c3c5e211caede Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 17 Aug 2026 17:47:30 +0200 Subject: [PATCH] refactor(plex): extract card serializers + query builder from browse.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second half of the 2026-08-17 review's "Plex query/cards extraction" — slim the 1841-line browse.py god-module ahead of S6 (which reworks the library query's column selection for the HU overlay). - app/plex/cards.py: the pure grid/leaf card serializers (_movie_card, _show_card, _episode_card, _leaf_card). - app/plex/query.py: the library query-shaping layer — field-scope tsvector rechecks (_TS_CONFIG, _FIELD_COLUMNS, _field_recheck) and the shared sidebar metadata filters (_csv, _added_cutoff, _meta_filter_conds, _apply_meta_filters, LibraryFilters, _like_escape). This is the seam S6 will touch. browse.py imports both and keeps its route handlers; the entangled collection/playlist card cluster (_collection_source/_collection_editable) stays put deliberately. All helpers were browse-internal, so no external importers change. Pure code motion — DB-lane suite 287 passed / 2 skipped; ruff clean. browse.py 1841 -> 1667 lines. --- backend/app/plex/cards.py | 65 ++++++++++ backend/app/plex/query.py | 133 ++++++++++++++++++++ backend/app/routes/plex/browse.py | 199 ++---------------------------- 3 files changed, 211 insertions(+), 186 deletions(-) create mode 100644 backend/app/plex/cards.py create mode 100644 backend/app/plex/query.py diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py new file mode 100644 index 00000000..efd2b25c --- /dev/null +++ b/backend/app/plex/cards.py @@ -0,0 +1,65 @@ +"""Plex list/leaf card serializers: the small `model -> dict` projections the library grid, +playlists, and detail views render. Extracted from routes/plex/browse.py so the row-shaping +lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the +router. Pure functions — no DB, no request state.""" + +from app.models import PlexItem, PlexShow, PlexState + + +def _movie_card(it: PlexItem, st: PlexState | None) -> dict: + return { + "id": it.rating_key, + "type": "movie", + "title": it.title, + "year": it.year, + "duration_seconds": it.duration_s, + "thumb": f"/api/plex/image/{it.rating_key}", + "playable": it.playable, + "status": st.status if st else "new", + "position_seconds": st.position_seconds if st else 0, + } + + +def _show_card(sh: PlexShow, status: str = "new") -> dict: + return { + "id": sh.rating_key, + "type": "show", + "title": sh.title, + "year": sh.year, + "thumb": f"/api/plex/image/{sh.rating_key}", + "season_count": sh.child_count, + # Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge. + "status": status, + } + + +def _episode_card(e: PlexItem, st: PlexState | None) -> dict: + return { + "id": e.rating_key, + "type": "episode", + "title": e.title, + "summary": e.summary, + "season_number": e.season_number, + "episode_number": e.episode_number, + "duration_seconds": e.duration_s, + "thumb": f"/api/plex/image/{e.rating_key}", + "playable": e.playable, + "status": st.status if st else "new", + "position_seconds": st.position_seconds if st else 0, + } + + +def _leaf_card( + it: PlexItem, + st: PlexState | None, + show_title: str | None = None, + show_rk: str | None = None, +) -> dict: + """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds. + `show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.""" + if it.kind == "episode": + card = _episode_card(it, st) + card["show_title"] = show_title + card["show_id"] = show_rk + return card + return _movie_card(it, st) diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py new file mode 100644 index 00000000..2d530561 --- /dev/null +++ b/backend/app/plex/query.py @@ -0,0 +1,133 @@ +"""Plex library query-shaping: the full-text field-scope rechecks and the sidebar metadata +filters that /library and /facets share. Extracted from routes/plex/browse.py so the query +builder has one home (and so the HU-overlay column selection lands here, not in the router). +Pure query fragments — every function returns SQLAlchemy expressions or a params dict.""" + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlalchemy import and_, func, or_ + +_TS_CONFIG = "public.unaccent_simple" + +# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE +# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to +# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column +# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions +# mirror the `search_vector` Computed definition (same `_TS_CONFIG`, same `left(summary, 1000)` +# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled +# separately in the query body (it targets episode rows, not the movie/show grid). +_FIELD_COLUMNS = ("title", "people", "summary", "episode") + + +def _field_recheck(model, field: str, tsq): + """A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None.""" + if field == "title": + expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.title, "")) + elif field == "people": + expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.people_text, "")) + elif field == "summary": + expr = func.to_tsvector( + _TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000) + ) + else: + return None + return expr.op("@@")(tsq) + + +def _csv(v: str | None) -> list[str]: + return [s.strip() for s in v.split(",") if s.strip()] if v else [] + + +_ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365} + + +def _added_cutoff(within: str | None) -> datetime | None: + days = _ADDED_WINDOWS.get(within or "") + return datetime.now(timezone.utc) - timedelta(days=days) if days else None + + +def _meta_filter_conds(model, p: dict) -> list: + """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for + `p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a + list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets.""" + conds: list = [] + gsel = _csv(p.get("genres")) + if gsel: + gc = [model.genres.contains([g]) for g in gsel] + conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc)) + crs = _csv(p.get("content_ratings")) + if crs: + conds.append(model.content_rating.in_(crs)) + if p.get("year_min") is not None: + conds.append(model.year >= p["year_min"]) + if p.get("year_max") is not None: + conds.append(model.year <= p["year_max"]) + if p.get("rating_min") is not None: + conds.append(model.rating >= p["rating_min"]) + cutoff = _added_cutoff(p.get("added_within")) + if cutoff is not None: + conds.append(model.added_at >= cutoff) + for d in _csv(p.get("directors")): # AND + conds.append(model.directors.contains([d])) + alist = _csv(p.get("actors")) + if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode + ac = [model.cast_names.contains([a]) for a in alist] + conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac)) + stds = _csv(p.get("studios")) + if stds: # OR + conds.append(model.studio.in_(stds)) + if p.get("collection"): + conds.append(model.collection_keys.contains([p["collection"]])) + return conds + + +def _apply_meta_filters(q, model, p: dict): + conds = _meta_filter_conds(model, p) + return q.where(*conds) if conds else q + + +@dataclass +class LibraryFilters: + """The shared sidebar filter query-params for /library + /facets, declared once and injected via + ``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included + for /facets' wsq but ignored by `_meta_filter_conds` (both callers apply duration separately).""" + + genres: str | None = None + genre_mode: str = "any" + content_ratings: str | None = None + year_min: int | None = None + year_max: int | None = None + rating_min: float | None = None + duration_min: int | None = None + duration_max: int | None = None + added_within: str | None = None + directors: str | None = None + actors: str | None = None + actor_mode: str = ( + "any" # how multiple actors combine: any (OR, default) | all (AND) + ) + studios: str | None = None + collection: str | None = None + + def as_dict(self) -> dict: + return { + "genres": self.genres, + "genre_mode": self.genre_mode, + "content_ratings": self.content_ratings, + "year_min": self.year_min, + "year_max": self.year_max, + "rating_min": self.rating_min, + "duration_min": self.duration_min, + "duration_max": self.duration_max, + "added_within": self.added_within, + "directors": self.directors, + "actors": self.actors, + "actor_mode": self.actor_mode, + "studios": self.studios, + "collection": self.collection, + } + + +def _like_escape(s: str) -> str: + return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index f6d71218..b0982d7e 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -3,8 +3,7 @@ import threading import time from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel @@ -43,40 +42,25 @@ 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 import sync as plex_sync +from app.feedquery import to_tsquery_str +from app.plex.cards import _episode_card, _leaf_card, _movie_card, _show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key -from app.feedquery import to_tsquery_str +from app.plex.query import ( + _FIELD_COLUMNS, + _TS_CONFIG, + LibraryFilters, + _apply_meta_filters, + _csv, + _field_recheck, + _like_escape, + _meta_filter_conds, +) from app.routes._common import owned_or_404 from app.routes.plex._shared import item_or_404 router = APIRouter(prefix="/api/plex", tags=["plex"]) -_TS_CONFIG = "public.unaccent_simple" - -# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE -# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to -# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column -# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions -# mirror the `search_vector` Computed definition (same `_TS_CONFIG`, same `left(summary, 1000)` -# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled -# separately in the query body (it targets episode rows, not the movie/show grid). -_FIELD_COLUMNS = ("title", "people", "summary", "episode") - - -def _field_recheck(model, field: str, tsq): - """A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None.""" - if field == "title": - expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.title, "")) - elif field == "people": - expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.people_text, "")) - elif field == "summary": - expr = func.to_tsvector( - _TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000) - ) - else: - return None - return expr.op("@@")(tsq) - @router.get("/libraries") def list_libraries( @@ -112,33 +96,6 @@ def list_libraries( return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out} -def _movie_card(it: PlexItem, st: PlexState | None) -> dict: - return { - "id": it.rating_key, - "type": "movie", - "title": it.title, - "year": it.year, - "duration_seconds": it.duration_s, - "thumb": f"/api/plex/image/{it.rating_key}", - "playable": it.playable, - "status": st.status if st else "new", - "position_seconds": st.position_seconds if st else 0, - } - - -def _show_card(sh: PlexShow, status: str = "new") -> dict: - return { - "id": sh.rating_key, - "type": "show", - "title": sh.title, - "year": sh.year, - "thumb": f"/api/plex/image/{sh.rating_key}", - "season_count": sh.child_count, - # Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge. - "status": status, - } - - 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).""" @@ -148,132 +105,6 @@ def _lib_key(db: Session, library_id: int | None) -> str | None: return lib.plex_key if lib else None -def _episode_card(e: PlexItem, st: PlexState | None) -> dict: - return { - "id": e.rating_key, - "type": "episode", - "title": e.title, - "summary": e.summary, - "season_number": e.season_number, - "episode_number": e.episode_number, - "duration_seconds": e.duration_s, - "thumb": f"/api/plex/image/{e.rating_key}", - "playable": e.playable, - "status": st.status if st else "new", - "position_seconds": st.position_seconds if st else 0, - } - - -def _leaf_card( - it: PlexItem, - st: PlexState | None, - show_title: str | None = None, - show_rk: str | None = None, -) -> dict: - """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds. - `show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.""" - if it.kind == "episode": - card = _episode_card(it, st) - card["show_title"] = show_title - card["show_id"] = show_rk - return card - return _movie_card(it, st) - - -def _csv(v: str | None) -> list[str]: - return [s.strip() for s in v.split(",") if s.strip()] if v else [] - - -_ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365} - - -def _added_cutoff(within: str | None) -> datetime | None: - days = _ADDED_WINDOWS.get(within or "") - return datetime.now(timezone.utc) - timedelta(days=days) if days else None - - -def _meta_filter_conds(model, p: dict) -> list: - """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for - `p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a - list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets.""" - conds: list = [] - gsel = _csv(p.get("genres")) - if gsel: - gc = [model.genres.contains([g]) for g in gsel] - conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc)) - crs = _csv(p.get("content_ratings")) - if crs: - conds.append(model.content_rating.in_(crs)) - if p.get("year_min") is not None: - conds.append(model.year >= p["year_min"]) - if p.get("year_max") is not None: - conds.append(model.year <= p["year_max"]) - if p.get("rating_min") is not None: - conds.append(model.rating >= p["rating_min"]) - cutoff = _added_cutoff(p.get("added_within")) - if cutoff is not None: - conds.append(model.added_at >= cutoff) - for d in _csv(p.get("directors")): # AND - conds.append(model.directors.contains([d])) - alist = _csv(p.get("actors")) - if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode - ac = [model.cast_names.contains([a]) for a in alist] - conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac)) - stds = _csv(p.get("studios")) - if stds: # OR - conds.append(model.studio.in_(stds)) - if p.get("collection"): - conds.append(model.collection_keys.contains([p["collection"]])) - return conds - - -def _apply_meta_filters(q, model, p: dict): - conds = _meta_filter_conds(model, p) - return q.where(*conds) if conds else q - - -@dataclass -class LibraryFilters: - """The shared sidebar filter query-params for /library + /facets, declared once and injected via - ``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included - for /facets' wsq but ignored by `_meta_filter_conds` (both callers apply duration separately).""" - - genres: str | None = None - genre_mode: str = "any" - content_ratings: str | None = None - year_min: int | None = None - year_max: int | None = None - rating_min: float | None = None - duration_min: int | None = None - duration_max: int | None = None - added_within: str | None = None - directors: str | None = None - actors: str | None = None - actor_mode: str = ( - "any" # how multiple actors combine: any (OR, default) | all (AND) - ) - studios: str | None = None - collection: str | None = None - - def as_dict(self) -> dict: - return { - "genres": self.genres, - "genre_mode": self.genre_mode, - "content_ratings": self.content_ratings, - "year_min": self.year_min, - "year_max": self.year_max, - "rating_min": self.rating_min, - "duration_min": self.duration_min, - "duration_max": self.duration_max, - "added_within": self.added_within, - "directors": self.directors, - "actors": self.actors, - "actor_mode": self.actor_mode, - "studios": self.studios, - "collection": self.collection, - } - - @router.get("/library") def unified_library( scope: str = "both", @@ -702,10 +533,6 @@ def facets( } -def _like_escape(s: str) -> str: - return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - - 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."""