fix(plex): address S6 code-review — 5 findings

1. (correctness) Show hero rating regression: PlexBrowse's show hero rendered a
   rating only when show.imdb_rating was set, so after the S6 IMDb-only change a
   show with no imdb:// entry showed no rating. Now falls back to the audience
   `rating` like PlexInfo.
2. (correctness) ratingMin column mismatch: the rating filter and the /facets
   rating_max now key off coalesce(imdb_rating, rating) — the SAME "best score"
   the badge displays — so a ratingMin picked from a shown IMDb score narrows the
   right column instead of the raw audience `rating`.
3. (altitude) HU search gap: new query.vector_match() matches EITHER vector in HU
   (or_(search_vector_hu, search_vector)) so a row with no HU twin stays findable
   by its English title; ranking still uses the HU vector. field_recheck unions
   the HU+EN per-field tsvectors in HU, staying a strict subset of the prefilter.
4. (test-coverage) Extracted the badge logic to lib/plexRating.pickRatingBadge
   (shared by PlexInfo + the show hero) with a unit test (imdb-preferred, audience
   fallback, 0-score, none). Backend tests added for the HU union search + the
   IMDb-preferred rating filter.
5. (efficiency) Language switch no longer double-fetches the grid: browseQ is
   gated on a committedLang state that lags `lang` by one render, so the
   genre-clear effect settles into a single fetch instead of one throwaway fetch
   with stale-language genres plus a second.

DB-lane suite 295 passed / 2 skip; FE tsc/knip/lint clean, 217 vitest pass.
This commit is contained in:
2026-08-17 20:41:13 +02:00
parent 2bfe7d53eb
commit 9fb4db2546
7 changed files with 142 additions and 40 deletions
+36 -13
View File
@@ -37,9 +37,22 @@ def genres_col(model, lang: str):
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
@@ -50,22 +63,29 @@ def vector_col(model, lang: str):
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.
Mirrors `vector_col`'s HU definition (raw `*_hu` coalesced to '') so a scoped hit stays a strict
subset of the language's GIN vector; `people` is language-neutral."""
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":
col = model.title_hu if hu else model.title
expr = func.to_tsvector(TS_CONFIG, func.coalesce(col, ""))
elif field == "people":
expr = func.to_tsvector(TS_CONFIG, func.coalesce(model.people_text, ""))
elif field == "summary":
col = model.summary_hu if hu else model.summary
expr = func.to_tsvector(TS_CONFIG, func.left(func.coalesce(col, ""), 1000))
else:
return None
return expr.op("@@")(tsq)
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]:
@@ -99,7 +119,10 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
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"])
# 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)
+6 -4
View File
@@ -57,6 +57,7 @@ from app.plex.query import (
split_csv,
title_col,
vector_col,
vector_match,
)
from app.routes._common import owned_or_404
from app.routes.plex._shared import item_or_404
@@ -206,7 +207,7 @@ def unified_library(
if f.duration_max is not None:
mq = mq.where(PlexItem.duration_s <= f.duration_max)
if tsq is not None:
mq = mq.where(vector_col(PlexItem, lang).op("@@")(tsq))
mq = mq.where(vector_match(PlexItem, lang, tsq))
if recheck is not None:
mq = mq.where(recheck(PlexItem, field, tsq, lang))
selects.append(mq)
@@ -251,7 +252,7 @@ def unified_library(
sq = sq.where(wat == 0, inp == 0)
sq = apply_meta_filters(sq, PlexShow, p, lang)
if tsq is not None:
sq = sq.where(vector_col(PlexShow, lang).op("@@")(tsq))
sq = sq.where(vector_match(PlexShow, lang, tsq))
if recheck is not None:
sq = sq.where(recheck(PlexShow, field, tsq, lang))
selects.append(sq)
@@ -492,10 +493,11 @@ def facets(
years.append(by[0])
if by[1] is not None:
years.append(by[1])
# Rating max — exclude the rating filter.
# Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the
# rating filter and the UI badge use (IMDb preferred, audience fallback).
br = db.scalar(
wsq(
select(func.max(model.rating)),
select(func.max(func.coalesce(model.imdb_rating, model.rating))),
model,
ids,
{**p, "rating_min": None},
+29
View File
@@ -94,3 +94,32 @@ def test_item_detail_language_and_rating_split(db, monkeypatch):
# imdb_rating is IMDb-only (None here); the audience score is served separately as `rating`.
assert body["imdb_rating"] is None
assert body["rating"] == 6.5
def test_hu_search_still_finds_en_only_titles(db, monkeypatch):
user = _seed(db)
# m2 ("EN Only") has no HU twin, so search_vector_hu carries no title tokens for it. HU-mode search
# must still match it via the EN vector (vector_match's union) — findable by its English title.
resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&q=Only")
assert "EN Only" in _titles(resp)
def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch):
lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
db.add(lib)
db.flush()
# imdb 8.0 but audience 4.0 → a rating_min of 7 must MATCH (filter the displayed best score,
# IMDb preferred), where filtering the raw audience column (4.0) would wrongly exclude it.
db.add(PlexItem(
rating_key="r1", library_id=lib.id, kind="movie", title="High IMDb",
imdb_rating=8.0, rating=4.0, year=2001,
added_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
))
user = User(email="rating@example.com")
db.add(user)
db.commit()
hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=7")
assert "High IMDb" in _titles(hit)
miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=9")
assert "High IMDb" not in _titles(miss)