Adds a movie-scoped "Playback" filter to the Plex feed so a user can isolate items by how they play — the groundwork for collecting HW-transcodable (or no-transcode) versions. Four classes derived from the stored `playable` + a new `transcode_hw` facet: - Direct Play (direct), Direct Stream (remux) - Transcode · HW — playable=transcode AND the iGPU can hardware-transcode it - Transcode · SW — playable=transcode, software libx264 only The HW/SW split is precise, not codec-only: a 10-bit/HDR HEVC is HW-decodable yet breaks the VAAPI nv12 pipeline (the same case plex/stream special-cases), so it must count as SW. Backend: - New PlexItem columns (migration 0077): transcode_hw, video_bit_depth, media_probed_at. Migration backfills transcode_hw codec-only so the filter works immediately. - `plex.sync.transcode_hw_capable` is the single classifier; the HW-decodable codec set moves to sync as `HW_DECODABLE_CODECS` and plex.stream imports it, so the catalog facet and the runtime encoder choice can't disagree by codec. - Bit-depth backfill (`_probe_media_batch`, wired into sync): probes only HW-decodable transcode items' video Stream for bit-depth + HDR and refines a 10-bit/HDR HEVC to software. Bounded per run, watermarked by media_probed_at. A codec change resets the watermark (stale probe). - Filter + self-excluded facet on a shared `playback_class_expr` CASE (movie-leg only — a show is an aggregate with no single class). transcode_hw threaded onto the feed/detail/card JSON. Frontend: - Multi-select "Playback" Combobox in the sidebar (movie scope, facet-empty drop-out), active chips, 4-way corner-dot badge (green/blue/amber/red) + tooltip, and an info-page playback chip with a codec · bit-depth subtitle. New `bg-info` (blue) token, i18n HU/EN parity. - Fix a regression where the card's playback dot lost its hover tooltip: the full-card reveal overlay swallowed the cursor; the dot now sits above it (z-20). Gate: backend full lane + frontend typecheck/lint/knip/prettier/vitest all green. Browser-UAT'd on localdev (filter narrows to the SW set, facet counts, badge colour + tooltip, info chip).
344 lines
16 KiB
Python
344 lines
16 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 app.models import PlexItem
|
||
|
||
TS_CONFIG = "public.unaccent_simple"
|
||
|
||
# --- Language-aware column pickers (E5 S6) ----------------------------------
|
||
#
|
||
# The HU-overlay twin (E5 S5) 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 S2
|
||
# 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)
|
||
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 (E5 S5)
|
||
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 (E7) — 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.
|
||
_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 (E5 S5), 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,
|
||
}
|
||
|
||
|
||
def like_escape(s: str) -> str:
|
||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||
|
||
|
||
# --- 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 (E3 S1; 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
|