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:
@@ -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