diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index 35c27f89..bbc539c7 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -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) diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 8716354e..0a33bcb8 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -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}, diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py index b1e94b9c..98dabe89 100644 --- a/backend/tests/test_plex_lang.py +++ b/backend/tests/test_plex_lang.py @@ -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) diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 74e5be65..a2c38c52 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -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 && ( · {t("plex.seasons", { count: show.season_count })} )} - {show.imdb_rating != null && ( - - )} + {(() => { + // 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 ( + + ); + })()} {show.genres.length > 0 && (
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index a76c670b..10de6c2a 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -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 diff --git a/frontend/src/lib/plexRating.test.ts b/frontend/src/lib/plexRating.test.ts new file mode 100644 index 00000000..dab7801a --- /dev/null +++ b/frontend/src/lib/plexRating.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/plexRating.ts b/frontend/src/lib/plexRating.ts new file mode 100644 index 00000000..40cf6af2 --- /dev/null +++ b/frontend/src/lib/plexRating.ts @@ -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 }; +}