refactor(plex): extract card serializers + query builder from browse.py

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.
This commit is contained in:
2026-08-17 17:47:30 +02:00
parent 5f09259bbf
commit e7b5b8e87f
3 changed files with 211 additions and 186 deletions
+65
View File
@@ -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)
+133
View File
@@ -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("_", "\\_")
+13 -186
View File
@@ -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."""