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
+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">