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)
+33 -20
View File
@@ -16,6 +16,7 @@ import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { plexLang } from "../i18n";
import { pickRatingBadge } from "../lib/plexRating";
import {
useInfiniteQuery,
useQuery,
@@ -121,6 +122,9 @@ export default function PlexBrowse() {
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
const lang = plexLang();
// The language the grid query is allowed to run at — lags `lang` by one render on a switch so the
// genre-clear effect (below) can settle first (see the effect for why).
const [committedLang, setCommittedLang] = useState(lang);
// Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per
// narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort.
const plexChips = usePlexActiveFilters();
@@ -196,7 +200,9 @@ export default function PlexBrowse() {
const browseQ = useInfiniteQuery({
queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang),
enabled: !!scope && sub.view.kind === "grid",
// Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect
// settles), not a throwaway one with stale-language genres followed by a second.
enabled: !!scope && sub.view.kind === "grid" && committedLang === lang,
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.plexLibrary({
@@ -218,14 +224,15 @@ export default function PlexBrowse() {
// Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a
// selection made in one language matches nothing against the other's column and would silently empty
// the grid. Drop the genre selection when the language flips (only on an actual change, not mount).
const prevLangRef = useRef(lang);
// the grid. On a language flip, drop the genre selection — and gate the grid query on the language
// being "committed" (below) so we don't fire one throwaway fetch with the stale-language genres and
// then a second after the clear; the query waits for both to settle into a single fetch.
useEffect(() => {
if (prevLangRef.current !== lang) {
prevLangRef.current = lang;
if (committedLang !== lang) {
setCommittedLang(lang);
if (filters.genres.length) setFilters({ ...filters, genres: [] });
}
}, [lang, filters, setFilters]);
}, [lang, committedLang, filters, setFilters]);
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
const total = browseQ.data?.pages[0]?.total ?? 0;
@@ -1413,20 +1420,26 @@ function PlexShowView({
{show.season_count != null && (
<span>· {t("plex.seasons", { count: show.season_count })}</span>
)}
{show.imdb_rating != null && (
<button
onClick={
onFilter
? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) })
: undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{show.imdb_rating}
</button>
)}
{(() => {
// IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show
// with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a
// rating instead of dropping the badge entirely.
const badge = pickRatingBadge(show.imdb_rating, show.rating);
if (!badge) return null;
const { score } = badge;
return (
<button
onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{score.toFixed(1)}
</button>
);
})()}
</div>
{show.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
+4 -3
View File
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { formatRuntime } from "../lib/format";
import { pickRatingBadge } from "../lib/plexRating";
import { notifyActionError } from "../lib/actionErrors";
import {
DetailCustomizeMenu,
@@ -198,9 +199,9 @@ export default function PlexInfo({
{(() => {
// IMDb score when we have one (with the IMDb link); otherwise the Plex audience
// score (no IMDb link/branding, so a non-IMDb score is never mislabelled as IMDb).
const score = detail.imdb_rating ?? detail.rating;
if (score == null) return null;
const isImdb = detail.imdb_rating != null;
const badge = pickRatingBadge(detail.imdb_rating, detail.rating);
if (!badge) return null;
const { score, isImdb } = badge;
return (
// decorative rating gold — intentionally NOT a status token
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { pickRatingBadge } from "./plexRating";
describe("pickRatingBadge", () => {
it("prefers the IMDb score and marks it as IMDb (gets the imdb.com link)", () => {
expect(pickRatingBadge(8.6, 6.0)).toEqual({ score: 8.6, isImdb: true });
});
it("falls back to the audience score without IMDb branding when there's no IMDb score", () => {
expect(pickRatingBadge(null, 7.2)).toEqual({ score: 7.2, isImdb: false });
expect(pickRatingBadge(undefined, 7.2)).toEqual({ score: 7.2, isImdb: false });
});
it("treats a 0 IMDb score as present (not falsy-skipped)", () => {
expect(pickRatingBadge(0, 5)).toEqual({ score: 0, isImdb: true });
});
it("returns null when neither score exists (no badge rendered)", () => {
expect(pickRatingBadge(null, null)).toBeNull();
expect(pickRatingBadge(undefined, undefined)).toBeNull();
});
});
+12
View File
@@ -0,0 +1,12 @@
// The rating badge shows the IMDb score when we have one (rendered with the imdb.com link),
// otherwise the Plex audience score (no IMDb link/branding) — so a non-IMDb score is never
// mislabelled as IMDb (E5 S6). Shared by the media-info panel (PlexInfo) and the show hero
// (PlexBrowse) so both surfaces agree. Returns null when neither score exists (no badge).
export function pickRatingBadge(
imdbRating?: number | null,
audienceRating?: number | null,
): { score: number; isImdb: boolean } | null {
const score = imdbRating ?? audienceRating;
if (score == null) return null;
return { score, isImdb: imdbRating != null };
}