feat(plex): expanded metadata filters, clickable info page, image disk cache

Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating
~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres /
directors / cast_names — all mirrored from the cheap section listing (no per-item API
calls; they also seed a future watch-habit recommender). /browse gains genre / content-
rating / year / rating / duration / added-within / director / actor / studio filters
(@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint
returns available genres+ratings (with counts) and the year/rating/duration bounds. A
thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local
disk after first fetch (~7-14x faster repeat loads).

Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating
steps, year range inputs, duration buckets, added-within, active people/studio chips,
clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year,
genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and
returns to the filtered grid (page variant only; the in-player overlay stays read-only
so a stray click can't stop playback). i18n en/hu/de.
This commit is contained in:
npeter83 2026-07-05 23:45:55 +02:00
parent 690e17611c
commit eefd7e3abd
15 changed files with 842 additions and 122 deletions

View file

@ -2,7 +2,7 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react";
import { api, type PlexCard } from "../lib/api";
import { api, type PlexCard, type PlexFilters } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
@ -22,7 +22,14 @@ type Sub =
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
// for now. Rendered by App for page==="plex" (its own nav module).
type Props = { q: string; library: string; show: string; sort: string };
type Props = {
q: string;
library: string;
show: string;
sort: string;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
};
const PAGE = 40;
@ -33,7 +40,7 @@ function dur(n?: number | null): string {
return h ? `${h}h ${m}m` : `${m}m`;
}
export default function PlexBrowse({ q, library, show, sort }: Props) {
export default function PlexBrowse({ q, library, show, sort, filters, setFilters }: Props) {
const { t } = useTranslation();
const qc = useQueryClient();
const dq = useDebounced(q.trim(), 350);
@ -53,11 +60,19 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
}, [sub.view.kind]);
const browseQ = useInfiniteQuery({
queryKey: ["plex-browse", library, dq, sort, show],
queryKey: ["plex-browse", library, dq, sort, show, filters],
enabled: !!library && sub.view.kind === "grid",
initialPageParam: 0,
queryFn: ({ pageParam }) =>
api.plexBrowse({ library, q: dq || undefined, sort, show, offset: pageParam as number, limit: PAGE }),
api.plexBrowse({
library,
q: dq || undefined,
sort,
show,
filters,
offset: pageParam as number,
limit: PAGE,
}),
getNextPageParam: (last) => {
const seen = last.offset + last.items.length;
return seen < last.total ? seen : undefined;
@ -111,6 +126,11 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
id={infoId}
onBack={sub.back}
onPlay={() => sub.open({ kind: "player", id: infoId })}
onFilter={(patch) => {
// Clicking a metadata chip sets that filter and returns to the (now filtered) grid.
setFilters({ ...filters, ...patch });
sub.back();
}}
/>
);
}
@ -273,10 +293,12 @@ function PlexInfoView({
id,
onBack,
onPlay,
onFilter,
}: {
id: string;
onBack: () => void;
onPlay: () => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
@ -295,7 +317,7 @@ function PlexInfoView({
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>
) : (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexInfo detail={q.data} variant="page" onPlay={onPlay} />
<PlexInfo detail={q.data} variant="page" onPlay={onPlay} onFilter={onFilter} />
</Suspense>
)}
</div>