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.
134 lines
5.4 KiB
Python
134 lines
5.4 KiB
Python
"""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("_", "\\_")
|