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:
parent
690e17611c
commit
eefd7e3abd
15 changed files with 842 additions and 122 deletions
|
|
@ -1,11 +1,14 @@
|
|||
import { useEffect } from "react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Film, SlidersHorizontal, Tv2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { ChevronRight, Film, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
|
||||
// The Plex module's left filter column (mirrors the feed Sidebar's shell): pick a library, filter
|
||||
// by watch state (movie libraries only), and sort. State is owned by App (per-account persisted).
|
||||
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
|
||||
// full metadata filter set (genre / rating / year / duration / added / content rating + the
|
||||
// director/actor/studio chips set by clicking the info page); shows keep library + sort only. State
|
||||
// is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the
|
||||
// backend so the sidebar only offers what the library actually contains.
|
||||
|
||||
type Props = {
|
||||
library: string;
|
||||
|
|
@ -14,12 +17,23 @@ type Props = {
|
|||
setShow: (v: string) => void;
|
||||
sort: string;
|
||||
setSort: (v: string) => void;
|
||||
filters: PlexFilters;
|
||||
setFilters: (f: PlexFilters) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
const SORT_OPTS = ["added", "title"] as const;
|
||||
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
||||
const SHOW_SORTS = ["added", "title"] as const;
|
||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||
// Duration buckets → [minSeconds|null, maxSeconds|null].
|
||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||||
{ key: "short", min: null, max: 90 * 60 },
|
||||
{ key: "mid", min: 90 * 60, max: 120 * 60 },
|
||||
{ key: "long", min: 120 * 60, max: null },
|
||||
];
|
||||
|
||||
export default function PlexSidebar({
|
||||
library,
|
||||
|
|
@ -28,6 +42,8 @@ export default function PlexSidebar({
|
|||
setShow,
|
||||
sort,
|
||||
setSort,
|
||||
filters,
|
||||
setFilters,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
|
|
@ -35,14 +51,38 @@ export default function PlexSidebar({
|
|||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
||||
const libs = libsQ.data?.libraries ?? [];
|
||||
const activeLib = libs.find((l) => l.key === library);
|
||||
const isMovieLib = activeLib?.kind === "movie";
|
||||
|
||||
const facetsQ = useQuery({
|
||||
queryKey: ["plex-facets", library],
|
||||
queryFn: () => api.plexFacets(library),
|
||||
enabled: !!library && isMovieLib,
|
||||
});
|
||||
const facets = facetsQ.data;
|
||||
|
||||
// Default to the first library once loaded (or if the stored one vanished).
|
||||
useEffect(() => {
|
||||
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
||||
}, [libs, library, setLibrary]);
|
||||
|
||||
const isMovieLib = activeLib?.kind === "movie";
|
||||
const activeCount = (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||||
const fCount = isMovieLib ? plexFilterCount(filters) : 0;
|
||||
const activeCount =
|
||||
fCount + (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||||
const anyActive = activeCount > 0;
|
||||
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
const toggleIn = (arr: string[], v: string) =>
|
||||
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
|
||||
const clearAll = () => {
|
||||
setFilters(EMPTY_PLEX_FILTERS);
|
||||
setShow("all");
|
||||
setSort("added");
|
||||
};
|
||||
|
||||
const sorts = isMovieLib ? MOVIE_SORTS : SHOW_SORTS;
|
||||
const durBucketKey = DURATION_BUCKETS.find(
|
||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||
)?.key;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
|
|
@ -74,8 +114,7 @@ export default function PlexSidebar({
|
|||
return (
|
||||
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
|
||||
{/* Library scope */}
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.library")}</div>
|
||||
<Section label={t("plex.filter.library")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{libs.map((l) => (
|
||||
<button
|
||||
|
|
@ -91,45 +130,232 @@ export default function PlexSidebar({
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Watch state (movie libraries only — an episode's state lives in the show drill-down) */}
|
||||
{anyActive && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t("plex.filter.clearAll")} ({activeCount})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Watch state (movie libraries only) */}
|
||||
{isMovieLib && (
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.show")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Section label={t("plex.filter.show")}>
|
||||
<ChipRow>
|
||||
{SHOW_OPTS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setShow(s)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||||
show === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
|
||||
{t(`plex.filter.showOpt.${s}`)}
|
||||
</button>
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Sort */}
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.sort")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{SORT_OPTS.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSort(s)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||||
sort === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
<Section label={t("plex.filter.sort")}>
|
||||
<ChipRow>
|
||||
{sorts.map((s) => (
|
||||
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
||||
{t(`plex.sort.${s}`)}
|
||||
</button>
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Metadata filters (movie libraries only) */}
|
||||
{isMovieLib && (
|
||||
<>
|
||||
{/* Active people / studio — set by clicking the info page. */}
|
||||
{(filters.director || filters.actor || filters.studio) && (
|
||||
<Section label={t("plex.filter.active")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filters.director && (
|
||||
<RemovableChip label={filters.director} onRemove={() => patch({ director: null })} />
|
||||
)}
|
||||
{filters.actor && (
|
||||
<RemovableChip label={filters.actor} onRemove={() => patch({ actor: null })} />
|
||||
)}
|
||||
{filters.studio && (
|
||||
<RemovableChip label={filters.studio} onRemove={() => patch({ studio: null })} />
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* IMDb rating min */}
|
||||
<Section label={t("plex.filter.rating")}>
|
||||
<ChipRow>
|
||||
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{RATING_STEPS.map((r) => (
|
||||
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
||||
{r}+
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Genres (from facets) */}
|
||||
{facets && facets.genres.length > 0 && (
|
||||
<Section label={t("plex.filter.genre")}>
|
||||
<ChipRow>
|
||||
{facets.genres.map((g) => (
|
||||
<Chip
|
||||
key={g.value}
|
||||
active={filters.genres.includes(g.value)}
|
||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||
>
|
||||
{g.value}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Year range */}
|
||||
<Section label={t("plex.filter.year")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
value={filters.yearMin ?? null}
|
||||
placeholder={facets?.year_min ?? undefined}
|
||||
onChange={(v) => patch({ yearMin: v })}
|
||||
/>
|
||||
<span className="text-muted text-xs">–</span>
|
||||
<NumberInput
|
||||
value={filters.yearMax ?? null}
|
||||
placeholder={facets?.year_max ?? undefined}
|
||||
onChange={(v) => patch({ yearMax: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Duration buckets */}
|
||||
<Section label={t("plex.filter.duration")}>
|
||||
<ChipRow>
|
||||
<Chip
|
||||
active={!durBucketKey && filters.durationMin == null && filters.durationMax == null}
|
||||
onClick={() => patch({ durationMin: null, durationMax: null })}
|
||||
>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{DURATION_BUCKETS.map((b) => (
|
||||
<Chip
|
||||
key={b.key}
|
||||
active={durBucketKey === b.key}
|
||||
onClick={() => patch({ durationMin: b.min, durationMax: b.max })}
|
||||
>
|
||||
{t(`plex.filter.durationOpt.${b.key}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Added to Plex */}
|
||||
<Section label={t("plex.filter.added")}>
|
||||
<ChipRow>
|
||||
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{ADDED_OPTS.map((a) => (
|
||||
<Chip key={a} active={filters.addedWithin === a} onClick={() => patch({ addedWithin: a })}>
|
||||
{t(`plex.filter.addedOpt.${a}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Content rating (from facets) */}
|
||||
{facets && facets.content_ratings.length > 0 && (
|
||||
<Section label={t("plex.filter.contentRating")}>
|
||||
<ChipRow>
|
||||
{facets.content_ratings.map((c) => (
|
||||
<Chip
|
||||
key={c.value}
|
||||
active={filters.contentRatings.includes(c.value)}
|
||||
onClick={() => patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })}
|
||||
>
|
||||
{c.value}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipRow({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-wrap gap-1">{children}</div>;
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||||
active ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs">
|
||||
<span className="truncate max-w-[9rem]">{label}</span>
|
||||
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: number | null;
|
||||
placeholder?: number;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder != null ? String(placeholder) : ""}
|
||||
onChange={(e) => {
|
||||
const n = e.target.value === "" ? null : Number(e.target.value);
|
||||
onChange(n != null && Number.isFinite(n) ? n : null);
|
||||
}}
|
||||
className="w-20 rounded-lg border border-border bg-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue