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:
+36
-13
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user