refactor: address code-review — layering + public API naming

Applies the three findings from the /code-review high pass on this stack:

1. to_tsquery_str moved from app.feedquery to app.utils — it's a generic
   tsquery sanitizer with no feed dependency; housing it in the feed
   read-model coupled the Plex browse module to the feed domain. Both feed
   and plex now import it from the neutral util module.

2. feedquery.decode_cursor no longer raises fastapi.HTTPException — it raises
   a plain ValueError, and routes/feed.get_feed translates it to the HTTP 400.
   The read-model no longer imports fastapi, matching its "no request state"
   docstring.

3. Dropped the leading underscore on the helpers that are now a cross-module
   public API: feedquery {filtered_query, sort_keys, order_by, keyset_predicate,
   encode_cursor, decode_cursor}; plex.query {TS_CONFIG, FIELD_COLUMNS,
   field_recheck, split_csv, added_cutoff, meta_filter_conds, apply_meta_filters,
   like_escape}; plex.cards {movie_card, show_card, episode_card, leaf_card}.
   Genuinely-internal helpers (_channel_url, _saved_expr, _ADDED_WINDOWS,
   _lib_key) keep their underscore.

Pure code motion + rename. DB-lane suite 287 passed / 2 skipped; ruff clean.
This commit is contained in:
2026-08-17 18:23:03 +02:00
parent e7b5b8e87f
commit 2ab188d873
6 changed files with 101 additions and 94 deletions
+20 -20
View File
@@ -8,55 +8,55 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, func, or_
_TS_CONFIG = "public.unaccent_simple"
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)`
# 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")
FIELD_COLUMNS = ("title", "people", "summary", "episode")
def _field_recheck(model, field: str, tsq):
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, ""))
expr = func.to_tsvector(TS_CONFIG, func.coalesce(model.title, ""))
elif field == "people":
expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.people_text, ""))
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)
TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000)
)
else:
return None
return expr.op("@@")(tsq)
def _csv(v: str | None) -> list[str]:
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:
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:
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"))
gsel = split_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"))
crs = split_csv(p.get("content_ratings"))
if crs:
conds.append(model.content_rating.in_(crs))
if p.get("year_min") is not None:
@@ -65,16 +65,16 @@ def _meta_filter_conds(model, p: dict) -> list:
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"))
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
for d in split_csv(p.get("directors")): # AND
conds.append(model.directors.contains([d]))
alist = _csv(p.get("actors"))
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 = _csv(p.get("studios"))
stds = split_csv(p.get("studios"))
if stds: # OR
conds.append(model.studio.in_(stds))
if p.get("collection"):
@@ -82,8 +82,8 @@ def _meta_filter_conds(model, p: dict) -> list:
return conds
def _apply_meta_filters(q, model, p: dict):
conds = _meta_filter_conds(model, p)
def apply_meta_filters(q, model, p: dict):
conds = meta_filter_conds(model, p)
return q.where(*conds) if conds else q
@@ -91,7 +91,7 @@ def _apply_meta_filters(q, model, p: dict):
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)."""
for /facets' wsq but ignored by `meta_filter_conds` (both callers apply duration separately)."""
genres: str | None = None
genre_mode: str = "any"
@@ -129,5 +129,5 @@ class LibraryFilters:
}
def _like_escape(s: str) -> str:
def like_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")