The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
388 lines
18 KiB
Python
388 lines
18 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_, case, func, or_, select, type_coerce
|
||
from sqlalchemy.dialects.postgresql import JSONB
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models import PlexItem, PlexLibrary
|
||
from app.plex.genres import hu_genre
|
||
|
||
TS_CONFIG = "public.unaccent_simple"
|
||
|
||
# The ONE Plex sort vocabulary, shared by every surface that offers the Sort control (the browse grid
|
||
# and the collection info page) and mirrored by the frontend's `plexSort.ts`. It lives here rather than
|
||
# inside a router because the two surfaces order DIFFERENT things — browse orders a union subquery in
|
||
# SQL, a collection orders its already-loaded member list in Python — and a key that one of them cannot
|
||
# honour would put an option in the UI that silently does nothing. Tests pin both implementations to
|
||
# this tuple, so adding a key here fails the gate until every surface can order by it.
|
||
PLEX_SORT_KEYS: tuple[str, ...] = (
|
||
"added",
|
||
"lastwatched",
|
||
"release",
|
||
"year",
|
||
"rating",
|
||
"duration",
|
||
"title",
|
||
)
|
||
|
||
# --- Language-aware column pickers ----------------------------------
|
||
#
|
||
# The HU-overlay twin stores Hungarian metadata in `*_hu` columns on the SAME EN row. When
|
||
# the app language is `hu` we serve those, falling back to the EN column where a row has no HU twin.
|
||
# Two flavours of picker, on purpose:
|
||
# * DISPLAY / FILTER (`title_col`/`genres_col`): `coalesce(*_hu, *)` — an EN fallback so a row with
|
||
# no HU metadata still shows (and filters on) its English value. (The detail endpoints serve the
|
||
# HU summary directly off the loaded row, so no `summary_col` SQL helper is needed.)
|
||
# * SEARCH (`vector_col` + `field_recheck`): the raw `*_hu` column coalesced to '' — MIRRORING the
|
||
# generated `search_vector_hu` definition (which uses `coalesce(title_hu, '')`, NOT an EN
|
||
# fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the
|
||
# field-search invariant). `people_text` is language-neutral and shared by both vectors.
|
||
|
||
|
||
def title_col(model, lang: str):
|
||
return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title
|
||
|
||
|
||
def summary_col(model, lang: str):
|
||
"""DISPLAY summary, HU-coalesced (EN fallback) — the card hover-overview + info page read this."""
|
||
return func.coalesce(model.summary_hu, model.summary) if lang == "hu" else model.summary
|
||
|
||
|
||
def genres_col(model, lang: str):
|
||
# type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce.
|
||
if lang == "hu":
|
||
return type_coerce(func.coalesce(model.genres_hu, model.genres), JSONB)
|
||
return model.genres
|
||
|
||
|
||
def top_art_keys(
|
||
db, model, lib_ids: list[int], key_col, keys: list[str], *, per: int = 4, extra_conds=()
|
||
) -> dict[str, list[str]]:
|
||
"""For each value in `keys` (a member of the JSONB array `key_col` — e.g. a genre value or a
|
||
collection rating_key), the rating_keys of its top `per` items (best-rated, then most-recent) as
|
||
{key: [rating_key, …]}. ONE windowed query: unnest the array, rank within each key, take the top.
|
||
The caller renders posters from /api/plex/image/{rating_key}, so only the keys are returned."""
|
||
if not lib_ids or not keys:
|
||
return {}
|
||
k = func.jsonb_array_elements_text(key_col).label("k")
|
||
expanded = (
|
||
select(model.rating_key.label("rk"), model.rating.label("r"), model.added_at.label("a"), k)
|
||
.where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array", *extra_conds)
|
||
.subquery()
|
||
)
|
||
ranked = select(
|
||
expanded.c.rk,
|
||
expanded.c.k,
|
||
func.row_number()
|
||
.over(
|
||
partition_by=expanded.c.k,
|
||
order_by=(expanded.c.r.desc().nullslast(), expanded.c.a.desc().nullslast()),
|
||
)
|
||
.label("rn"),
|
||
).subquery()
|
||
out: dict[str, list[str]] = {}
|
||
for rk, key in db.execute(
|
||
select(ranked.c.rk, ranked.c.k)
|
||
.where(ranked.c.k.in_(keys), ranked.c.rn <= per)
|
||
.order_by(ranked.c.k, ranked.c.rn)
|
||
).all():
|
||
out.setdefault(key, []).append(rk)
|
||
return out
|
||
|
||
|
||
def runtime_by_key(db, model, lib_ids: list[int], key_col, keys: list[str]) -> dict[str, int]:
|
||
"""Total runtime (sum of duration_s, seconds) of the items under each key in `keys` (a member of the
|
||
JSONB array `key_col`), as {key: seconds}. ONE grouped query. Used for a collection tile's runtime."""
|
||
if not lib_ids or not keys:
|
||
return {}
|
||
k = func.jsonb_array_elements_text(key_col).label("k")
|
||
exp = (
|
||
select(func.coalesce(model.duration_s, 0).label("d"), k)
|
||
.where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array")
|
||
.subquery()
|
||
)
|
||
rows = db.execute(
|
||
select(exp.c.k, func.sum(exp.c.d)).where(exp.c.k.in_(keys)).group_by(exp.c.k)
|
||
).all()
|
||
return {key: int(total or 0) for key, total in rows}
|
||
|
||
|
||
def vector_col(model, lang: str):
|
||
# RANKING vector. In HU we rank by the Hungarian vector; a row matched only via its English vector
|
||
# (see `vector_match`) gets rank 0 and sorts last, which is the intended HU-first ordering.
|
||
return model.search_vector_hu if lang == "hu" else model.search_vector
|
||
|
||
|
||
def vector_match(model, lang: str, tsq):
|
||
"""The full-text WHERE clause. In HU, match EITHER vector so a row with no HU twin stays findable
|
||
by its English title (search_vector_hu has no title tokens for such a row); EN matches only the EN
|
||
vector. Ranking still uses `vector_col` (the HU vector in HU mode)."""
|
||
if lang == "hu":
|
||
return or_(model.search_vector_hu.op("@@")(tsq), model.search_vector.op("@@")(tsq))
|
||
return model.search_vector.op("@@")(tsq)
|
||
|
||
|
||
# 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 _title_tsv(col):
|
||
return func.to_tsvector(TS_CONFIG, func.coalesce(col, ""))
|
||
|
||
|
||
def _summary_tsv(col):
|
||
return func.to_tsvector(TS_CONFIG, func.left(func.coalesce(col, ""), 1000))
|
||
|
||
|
||
def field_recheck(model, field: str, tsq, lang: str = "en"):
|
||
"""A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None.
|
||
Each per-field tsvector mirrors the generated `search_vector[_hu]` definition (same TS_CONFIG,
|
||
same `left(summary, 1000)`). In HU, title/summary re-check BOTH the HU and EN columns so a scoped
|
||
hit stays a strict subset of `vector_match`'s HU union; `people` is language-neutral."""
|
||
hu = lang == "hu"
|
||
if field == "title":
|
||
cols = [model.title_hu, model.title] if hu else [model.title]
|
||
return or_(*[_title_tsv(c).op("@@")(tsq) for c in cols])
|
||
if field == "people":
|
||
return func.to_tsvector(TS_CONFIG, func.coalesce(model.people_text, "")).op("@@")(tsq)
|
||
if field == "summary":
|
||
cols = [model.summary_hu, model.summary] if hu else [model.summary]
|
||
return or_(*[_summary_tsv(c).op("@@")(tsq) for c in cols])
|
||
return None
|
||
|
||
|
||
def split_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, lang: str = "en") -> 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.
|
||
Genre chips are language-specific strings, so they filter the active-language genre column."""
|
||
conds: list = []
|
||
gsel = split_csv(p.get("genres"))
|
||
if gsel:
|
||
gcol = genres_col(model, lang)
|
||
if lang == "hu":
|
||
# The HU column holds one Hungarian word per genre (plex/genres.py), so a filter saved while
|
||
# it offered "Action" is read as "Akció" too. The word as picked still matches as well: a row
|
||
# the sync has not reached since keeps its English (or Plex's mixed) list until it does.
|
||
gc = [or_(*(gcol.contains([w]) for w in dict.fromkeys((hu_genre(g), g)))) for g in gsel]
|
||
else:
|
||
gc = [gcol.contains([g]) for g in gsel]
|
||
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
|
||
crs = split_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:
|
||
# Filter by the SAME score the UI shows on the badge (IMDb preferred, audience fallback), so a
|
||
# ratingMin picked from a displayed IMDb score narrows the right column instead of the raw
|
||
# audience `rating`. Both are 0–10, so coalesce is scale-consistent.
|
||
conds.append(func.coalesce(model.imdb_rating, 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 split_csv(p.get("directors")): # AND
|
||
conds.append(model.directors.contains([d]))
|
||
alist = split_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 = split_csv(p.get("studios"))
|
||
if stds: # OR
|
||
conds.append(model.studio.in_(stds))
|
||
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page
|
||
conds.append(model.keywords.contains([kw]))
|
||
csel = split_csv(p.get("collections"))
|
||
if (
|
||
csel
|
||
): # OR (any) or AND (all) — a title can belong to several collections, mirroring genre_mode
|
||
cc = [model.collection_keys.contains([c]) for c in csel]
|
||
conds.append(and_(*cc) if p.get("collection_mode") == "all" else or_(*cc))
|
||
return conds
|
||
|
||
|
||
def apply_meta_filters(q, model, p: dict, lang: str = "en"):
|
||
conds = meta_filter_conds(model, p, lang)
|
||
return q.where(*conds) if conds else q
|
||
|
||
|
||
# --- Playback-class filter — MOVIE-ONLY (a show is an aggregate with no single class) ---------
|
||
# 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).
|
||
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
|
||
|
||
|
||
def playback_class_expr():
|
||
"""SQL CASE mapping a PlexItem (movie) to its playback-class string, or NULL. The HW branch must
|
||
precede the generic transcode branch (ordered evaluation)."""
|
||
return case(
|
||
(PlexItem.playable == "direct", "direct"),
|
||
(PlexItem.playable == "remux", "remux"),
|
||
(and_(PlexItem.playable == "transcode", PlexItem.transcode_hw.is_(True)), "transcode_hw"),
|
||
(PlexItem.playable == "transcode", "transcode_sw"),
|
||
else_=None,
|
||
)
|
||
|
||
|
||
def playable_conds(sel: list[str]) -> list:
|
||
"""A one-element condition list (or empty) selecting the given playback classes — an OR via
|
||
`class IN (...)` over the shared CASE. Unknown keys are dropped."""
|
||
keep = [c for c in sel if c in PLAYBACK_CLASSES]
|
||
return [playback_class_expr().in_(keep)] if keep else []
|
||
|
||
|
||
@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
|
||
keywords: str | None = None # TMDB keyword chips, CSV; language-neutral
|
||
collections: str | None = None # CSV of collection rating_keys
|
||
collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
|
||
playable: str | None = None # CSV of playback classes: direct|remux|transcode_hw|transcode_sw
|
||
|
||
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,
|
||
"keywords": self.keywords,
|
||
"collections": self.collections,
|
||
"collection_mode": self.collection_mode,
|
||
"playable": self.playable,
|
||
}
|
||
|
||
|
||
# --- Watch-state scope (shared by the /library grid AND the /facets aggregation) ---------------
|
||
#
|
||
# The `show` scope (all | watched | in_progress | unwatched) filters the browse result by per-user
|
||
# watch state. The SAME predicate has to be applied in two places — `unified_library` (the grid) and
|
||
# `facets.wsq` (so the facet counts reflect exactly the visible set) — and they MUST stay identical:
|
||
# a hidden movie kept out of the grid must not inflate the facets either. These helpers are the one
|
||
# home for that predicate so the two call sites can't drift (pinned by test_plex_facets_grid_
|
||
# parity). Callers pass the already-joined state expressions, so this module stays model-agnostic:
|
||
# * movies — `st` is an aliased PlexState outer-joined on (item_id, user_id).
|
||
# * shows — `tot`/`wat`/`inp` are the coalesced totals from `rollup.agg_subquery`.
|
||
|
||
|
||
def movie_status_case(st):
|
||
"""The per-movie status label (new | in_progress | watched) from its PlexState alias `st`."""
|
||
return case(
|
||
(st.status == "watched", "watched"),
|
||
(
|
||
and_(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")),
|
||
"in_progress",
|
||
),
|
||
else_="new",
|
||
)
|
||
|
||
|
||
def show_status_case(tot, wat, inp):
|
||
"""The per-show status label rolled up from its episodes' totals (total/watched/in-progress)."""
|
||
return case(
|
||
(and_(tot > 0, wat >= tot), "watched"),
|
||
(or_(wat > 0, inp > 0), "in_progress"),
|
||
else_="new",
|
||
)
|
||
|
||
|
||
def apply_movie_state_filter(query, st, show: str):
|
||
"""Restrict a movie query to the `show` scope via its PlexState alias `st`. The default (`all`)
|
||
excludes hidden movies — the exclusion that must match between grid and facets."""
|
||
if show == "watched":
|
||
return query.where(st.status == "watched")
|
||
if show == "in_progress":
|
||
return query.where(
|
||
st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")
|
||
)
|
||
if show == "unwatched":
|
||
return query.where(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
||
return query.where(or_(st.status.is_(None), st.status != "hidden"))
|
||
|
||
|
||
def apply_show_state_filter(query, tot, wat, inp, show: str):
|
||
"""Restrict a show query to the `show` scope via its rollup totals. `all` is a no-op (shows have
|
||
no hidden concept); the caller only joins the rollup for the three non-`all` scopes."""
|
||
if show == "watched":
|
||
return query.where(tot > 0, wat >= tot)
|
||
if show == "in_progress":
|
||
return query.where(or_(wat > 0, inp > 0), wat < tot)
|
||
if show == "unwatched":
|
||
return query.where(wat == 0, inp == 0)
|
||
return query
|
||
|
||
|
||
def content_libs(db: Session) -> tuple[list[int], list[int]]:
|
||
"""The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single
|
||
source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never
|
||
browsed on their own, they only lend their Hungarian metadata to the EN twin's rows.
|
||
|
||
Lives here, in the query layer, rather than in `routes/plex/_shared.py` where it started: the
|
||
Home surface needs the same scope, and a domain package importing from `routes/` would invert the
|
||
one-way dependency CLAUDE.md draws. It never touched request state, so nothing was lost by the
|
||
move."""
|
||
libs = (
|
||
db.execute(
|
||
select(PlexLibrary).where(
|
||
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
|
||
)
|
||
)
|
||
.scalars()
|
||
.all()
|
||
)
|
||
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
|
||
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
|
||
return movie_lib_ids, show_lib_ids
|