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:
+20
-20
@@ -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("_", "\\_")
|
||||
|
||||
Reference in New Issue
Block a user