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,12 +1,14 @@
|
|||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
api,
|
||||
EMPTY_PLEX_FILTERS,
|
||||
getActiveAccount,
|
||||
setActiveAccount,
|
||||
setUnauthorizedHandler,
|
||||
type FeedFilters,
|
||||
type PlexFilters,
|
||||
} from "./lib/api";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
import {
|
||||
|
|
@ -247,6 +249,16 @@ export default function App() {
|
|||
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
|
||||
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
||||
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
|
||||
const plexFilters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(plexFiltersRaw) };
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}, [plexFiltersRaw]);
|
||||
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
|
||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
||||
// there to see a specific set (the header's "without full history" link).
|
||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||
|
|
@ -727,6 +739,8 @@ export default function App() {
|
|||
setShow={setPlexShowFilter}
|
||||
sort={plexSort}
|
||||
setSort={setPlexSort}
|
||||
filters={plexFilters}
|
||||
setFilters={setPlexFilters}
|
||||
collapsed={filterCollapsed}
|
||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||
/>
|
||||
|
|
@ -802,7 +816,14 @@ export default function App() {
|
|||
onOpenWizard={() => setWizardOpen(true)}
|
||||
/>
|
||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||
<PlexBrowse q={filters.q} library={plexLib} show={plexShowFilter} sort={plexSort} />
|
||||
<PlexBrowse
|
||||
q={filters.q}
|
||||
library={plexLib}
|
||||
show={plexShowFilter}
|
||||
sort={plexSort}
|
||||
filters={plexFilters}
|
||||
setFilters={setPlexFilters}
|
||||
/>
|
||||
) : (
|
||||
<Feed
|
||||
filters={filters}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
|
|
@ -10,7 +10,7 @@ import {
|
|||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type PlexItemDetail } from "../lib/api";
|
||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
|
|
@ -24,8 +24,31 @@ type Props = {
|
|||
onPlay?: () => void; // page: start/resume playback
|
||||
onClose?: () => void; // overlay: dismiss
|
||||
onStateChange?: () => void; // after a watch-state change, let the opener refresh
|
||||
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
|
||||
// the corresponding Plex filter (then the opener navigates to the filtered grid).
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
};
|
||||
|
||||
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
|
||||
function Filterable({
|
||||
onClick,
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!onClick) return <span className={className}>{children}</span>;
|
||||
return (
|
||||
<button onClick={onClick} title={title} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDur(s?: number | null): string {
|
||||
if (!s) return "";
|
||||
const h = Math.floor(s / 3600);
|
||||
|
|
@ -33,7 +56,7 @@ function fmtDur(s?: number | null): string {
|
|||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange }: Props) {
|
||||
export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange, onFilter }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
|
||||
|
|
@ -59,15 +82,9 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
|||
onStateChange?.();
|
||||
};
|
||||
|
||||
const meta = [
|
||||
detail.year,
|
||||
detail.content_rating,
|
||||
fmtDur(detail.duration_seconds),
|
||||
detail.studio,
|
||||
].filter(Boolean);
|
||||
|
||||
const cast = showCast ? detail.cast : [];
|
||||
const overlay = variant === "overlay";
|
||||
const mutedCls = overlay ? "text-white/70" : "text-muted";
|
||||
|
||||
return (
|
||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}>
|
||||
|
|
@ -141,27 +158,50 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
|||
/>
|
||||
|
||||
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
||||
{/* Meta line + IMDb rating/link */}
|
||||
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{meta.map((m, i) => (
|
||||
<span key={i} className={overlay ? "text-white/70" : "text-muted"}>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
{detail.imdb_rating != null && (
|
||||
<a
|
||||
href={detail.imdb_url ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 font-semibold ${
|
||||
detail.imdb_url ? "bg-amber-400/15 text-amber-500 hover:bg-amber-400/25" : ""
|
||||
}`}
|
||||
title={detail.imdb_url ? t("plex.info.openImdb") : undefined}
|
||||
{detail.year != null && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
|
||||
onClick={onFilter ? () => onFilter({ yearMin: detail.year, yearMax: detail.year }) : undefined}
|
||||
>
|
||||
<Star className="w-3.5 h-3.5 fill-current" />
|
||||
{detail.imdb_rating.toFixed(1)}
|
||||
{detail.imdb_url && <ExternalLink className="w-3 h-3 opacity-70" />}
|
||||
</a>
|
||||
{detail.year}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.content_rating && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
onClick={onFilter ? () => onFilter({ contentRatings: [detail.content_rating!] }) : undefined}
|
||||
>
|
||||
{detail.content_rating}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.duration_seconds ? <span className={mutedCls}>{fmtDur(detail.duration_seconds)}</span> : null}
|
||||
{detail.studio && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
onClick={onFilter ? () => onFilter({ studio: detail.studio! }) : undefined}
|
||||
>
|
||||
{detail.studio}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.imdb_rating != null && (
|
||||
<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">
|
||||
<button
|
||||
onClick={onFilter ? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) }) : undefined}
|
||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
||||
title={onFilter ? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) }) : undefined}
|
||||
>
|
||||
<Star className="w-3.5 h-3.5 fill-current" />
|
||||
{detail.imdb_rating.toFixed(1)}
|
||||
</button>
|
||||
{detail.imdb_url && (
|
||||
<a href={detail.imdb_url} target="_blank" rel="noopener noreferrer" title={t("plex.info.openImdb")}>
|
||||
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -174,19 +214,31 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
|||
{detail.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{detail.genres.map((g) => (
|
||||
<span
|
||||
<Filterable
|
||||
key={g}
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
</span>
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.directors.length > 0 && (
|
||||
<p className={`mt-2 text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
||||
{t("plex.info.director")}: <span className="font-medium">{detail.directors.join(", ")}</span>
|
||||
<p className={`mt-2 text-sm ${mutedCls}`}>
|
||||
{t("plex.info.director")}:{" "}
|
||||
{detail.directors.map((d, i) => (
|
||||
<span key={d}>
|
||||
<Filterable
|
||||
className="font-medium"
|
||||
onClick={onFilter ? () => onFilter({ director: d }) : undefined}
|
||||
>
|
||||
{d}
|
||||
</Filterable>
|
||||
{i < detail.directors.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
|
@ -246,29 +298,51 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
|||
{t("plex.info.cast")}
|
||||
</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{cast.map((c, i) => (
|
||||
<div key={i} className="w-20 shrink-0 text-center">
|
||||
<div
|
||||
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"}`}
|
||||
>
|
||||
{c.thumb ? (
|
||||
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-lg opacity-40">
|
||||
{c.name.charAt(0)}
|
||||
{cast.map((c, i) => {
|
||||
const inner = (
|
||||
<>
|
||||
<div
|
||||
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
|
||||
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
||||
} ${onFilter ? "group-hover/cast:border-accent" : ""}`}
|
||||
>
|
||||
{c.thumb ? (
|
||||
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-lg opacity-40">
|
||||
{c.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
|
||||
onFilter ? "group-hover/cast:text-accent" : ""
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</div>
|
||||
{c.role && (
|
||||
<div className={`truncate text-[11px] ${overlay ? "text-white/55" : "text-muted"}`}>
|
||||
{c.role}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return onFilter ? (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onFilter({ actor: c.name })}
|
||||
title={t("plex.info.filterActor", { name: c.name })}
|
||||
className="group/cast w-20 shrink-0 text-center"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<div key={i} className="w-20 shrink-0 text-center">
|
||||
{inner}
|
||||
</div>
|
||||
<div className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}>
|
||||
{c.name}
|
||||
</div>
|
||||
{c.role && (
|
||||
<div className={`truncate text-[11px] ${overlay ? "text-white/55" : "text-muted"}`}>
|
||||
{c.role}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"searchPlaceholder": "Plex durchsuchen…",
|
||||
"sort": {
|
||||
"added": "Kürzlich hinzugefügt",
|
||||
"title": "Titel"
|
||||
"title": "Titel",
|
||||
"year": "Jahr",
|
||||
"rating": "IMDb-Wertung",
|
||||
"duration": "Länge",
|
||||
"release": "Erscheinungsdatum"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Bibliothek",
|
||||
|
|
@ -16,7 +20,18 @@
|
|||
"in_progress": "Läuft",
|
||||
"watched": "Gesehen"
|
||||
},
|
||||
"sort": "Sortierung"
|
||||
"sort": "Sortierung",
|
||||
"clearAll": "Filter zurücksetzen",
|
||||
"active": "Aktiv",
|
||||
"any": "Alle",
|
||||
"rating": "IMDb-Wertung",
|
||||
"genre": "Genre",
|
||||
"year": "Jahr",
|
||||
"duration": "Länge",
|
||||
"durationOpt": { "short": "Unter 90 Min.", "mid": "90–120 Min.", "long": "Über 2 Std." },
|
||||
"added": "Zu Plex hinzugefügt",
|
||||
"addedOpt": { "24h": "24 Std.", "1w": "1 Woche", "1m": "1 Monat", "6m": "6 Monate", "1y": "1 Jahr" },
|
||||
"contentRating": "Altersfreigabe"
|
||||
},
|
||||
"count": "{{count}} Titel",
|
||||
"searchCount": "{{count}} Treffer",
|
||||
|
|
@ -76,7 +91,10 @@
|
|||
"cast": "Besetzung & Crew",
|
||||
"markWatched": "Als gesehen markieren",
|
||||
"markUnwatched": "Gesehen zurücknehmen",
|
||||
"clearResume": "Fortsetzungsposition löschen"
|
||||
"clearResume": "Fortsetzungsposition löschen",
|
||||
"filterYear": "Titel aus {{year}}",
|
||||
"filterRating": "Titel mit {{n}}+",
|
||||
"filterActor": "Titel mit {{name}}"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Spielt direkt im Browser",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"searchPlaceholder": "Search Plex…",
|
||||
"sort": {
|
||||
"added": "Recently added",
|
||||
"title": "Title"
|
||||
"title": "Title",
|
||||
"year": "Year",
|
||||
"rating": "IMDb rating",
|
||||
"duration": "Length",
|
||||
"release": "Release date"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Library",
|
||||
|
|
@ -16,7 +20,18 @@
|
|||
"in_progress": "In progress",
|
||||
"watched": "Watched"
|
||||
},
|
||||
"sort": "Sort"
|
||||
"sort": "Sort",
|
||||
"clearAll": "Clear filters",
|
||||
"active": "Active",
|
||||
"any": "Any",
|
||||
"rating": "IMDb rating",
|
||||
"genre": "Genre",
|
||||
"year": "Year",
|
||||
"duration": "Length",
|
||||
"durationOpt": { "short": "Under 90m", "mid": "90–120m", "long": "Over 2h" },
|
||||
"added": "Added to Plex",
|
||||
"addedOpt": { "24h": "24h", "1w": "1 week", "1m": "1 month", "6m": "6 months", "1y": "1 year" },
|
||||
"contentRating": "Age rating"
|
||||
},
|
||||
"count": "{{count}} titles",
|
||||
"searchCount": "{{count}} matches",
|
||||
|
|
@ -76,7 +91,10 @@
|
|||
"cast": "Cast & crew",
|
||||
"markWatched": "Mark watched",
|
||||
"markUnwatched": "Mark unwatched",
|
||||
"clearResume": "Clear resume position"
|
||||
"clearResume": "Clear resume position",
|
||||
"filterYear": "Show titles from {{year}}",
|
||||
"filterRating": "Show titles rated {{n}}+",
|
||||
"filterActor": "Show titles with {{name}}"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Plays directly in the browser",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"searchPlaceholder": "Keresés a Plexben…",
|
||||
"sort": {
|
||||
"added": "Nemrég hozzáadott",
|
||||
"title": "Cím"
|
||||
"title": "Cím",
|
||||
"year": "Év",
|
||||
"rating": "IMDb pont",
|
||||
"duration": "Hossz",
|
||||
"release": "Megjelenés dátuma"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Könyvtár",
|
||||
|
|
@ -16,7 +20,18 @@
|
|||
"in_progress": "Folyamatban",
|
||||
"watched": "Megnézett"
|
||||
},
|
||||
"sort": "Rendezés"
|
||||
"sort": "Rendezés",
|
||||
"clearAll": "Szűrők törlése",
|
||||
"active": "Aktív",
|
||||
"any": "Bármi",
|
||||
"rating": "IMDb pont",
|
||||
"genre": "Műfaj",
|
||||
"year": "Év",
|
||||
"duration": "Hossz",
|
||||
"durationOpt": { "short": "90 perc alatt", "mid": "90–120 perc", "long": "2 óra felett" },
|
||||
"added": "Plexhez adva",
|
||||
"addedOpt": { "24h": "24 óra", "1w": "1 hét", "1m": "1 hónap", "6m": "6 hónap", "1y": "1 év" },
|
||||
"contentRating": "Korhatár"
|
||||
},
|
||||
"count": "{{count}} cím",
|
||||
"searchCount": "{{count}} találat",
|
||||
|
|
@ -76,7 +91,10 @@
|
|||
"cast": "Szereplők & stáb",
|
||||
"markWatched": "Megjelölés megnézettként",
|
||||
"markUnwatched": "Megnézett visszavonása",
|
||||
"clearResume": "Folytatási pozíció törlése"
|
||||
"clearResume": "Folytatási pozíció törlése",
|
||||
"filterYear": "{{year}} évi címek",
|
||||
"filterRating": "{{n}}+ pontos címek",
|
||||
"filterActor": "Címek {{name}} szereplésével"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Közvetlenül játszható a böngészőben",
|
||||
|
|
|
|||
|
|
@ -720,6 +720,43 @@ export interface PlexCastMember {
|
|||
role?: string | null;
|
||||
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
|
||||
}
|
||||
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
|
||||
export interface PlexFilters {
|
||||
genres: string[];
|
||||
contentRatings: string[];
|
||||
yearMin?: number | null;
|
||||
yearMax?: number | null;
|
||||
ratingMin?: number | null;
|
||||
durationMin?: number | null; // seconds
|
||||
durationMax?: number | null;
|
||||
addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y"
|
||||
director?: string | null;
|
||||
actor?: string | null;
|
||||
studio?: string | null;
|
||||
}
|
||||
export const EMPTY_PLEX_FILTERS: PlexFilters = { genres: [], contentRatings: [] };
|
||||
export function plexFilterCount(f: PlexFilters): number {
|
||||
return (
|
||||
f.genres.length +
|
||||
f.contentRatings.length +
|
||||
(f.yearMin != null || f.yearMax != null ? 1 : 0) +
|
||||
(f.ratingMin != null ? 1 : 0) +
|
||||
(f.durationMin != null || f.durationMax != null ? 1 : 0) +
|
||||
(f.addedWithin ? 1 : 0) +
|
||||
(f.director ? 1 : 0) +
|
||||
(f.actor ? 1 : 0) +
|
||||
(f.studio ? 1 : 0)
|
||||
);
|
||||
}
|
||||
export interface PlexFacets {
|
||||
genres: { value: string; count: number }[];
|
||||
content_ratings: { value: string; count: number }[];
|
||||
year_min: number | null;
|
||||
year_max: number | null;
|
||||
rating_max: number | null;
|
||||
duration_min: number | null;
|
||||
duration_max: number | null;
|
||||
}
|
||||
export interface PlexPlaySession {
|
||||
mode: "direct" | "hls";
|
||||
url: string;
|
||||
|
|
@ -1049,6 +1086,7 @@ export const api = {
|
|||
show?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
filters?: PlexFilters;
|
||||
}): Promise<PlexBrowseResult> => {
|
||||
const u = new URLSearchParams({ library: p.library });
|
||||
if (p.q) u.set("q", p.q);
|
||||
|
|
@ -1056,8 +1094,24 @@ export const api = {
|
|||
if (p.show && p.show !== "all") u.set("show", p.show);
|
||||
if (p.offset) u.set("offset", String(p.offset));
|
||||
if (p.limit) u.set("limit", String(p.limit));
|
||||
const f = p.filters;
|
||||
if (f) {
|
||||
if (f.genres?.length) u.set("genres", f.genres.join(","));
|
||||
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
|
||||
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
|
||||
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
|
||||
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
|
||||
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
|
||||
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
|
||||
if (f.addedWithin) u.set("added_within", f.addedWithin);
|
||||
if (f.director) u.set("director", f.director);
|
||||
if (f.actor) u.set("actor", f.actor);
|
||||
if (f.studio) u.set("studio", f.studio);
|
||||
}
|
||||
return req(`/api/plex/browse?${u.toString()}`);
|
||||
},
|
||||
plexFacets: (library: string): Promise<PlexFacets> =>
|
||||
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ export interface ReleaseEntry {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.26.0",
|
||||
date: "2026-07-05",
|
||||
summary: "Powerful Plex filters — by genre, IMDb rating, year, length, added date and age rating — and click any detail on the info page to filter by it. Plus much faster poster loading.",
|
||||
features: [
|
||||
"Plex library: a greatly expanded filter panel for movies — genre, IMDb rating, year range, length, when it was added to Plex, and age rating — plus new sort orders (release date, year, IMDb rating, length).",
|
||||
"Plex info page: the year, genres, director, cast and studio are now clickable — tap one to filter the whole library by it (e.g. every film with an actor, or everything rated 7+).",
|
||||
"Plex: posters and cast photos are cached on disk after first view, so scrolling the library is much smoother on repeat visits.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.25.0",
|
||||
date: "2026-07-05",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const LS = {
|
|||
plexLibrary: "siftlode.plexLibrary",
|
||||
plexShow: "siftlode.plexShow",
|
||||
plexSort: "siftlode.plexSort",
|
||||
plexFilters: "siftlode.plexFilters",
|
||||
notifHistory: "siftlode.notifications",
|
||||
notifSettings: "siftlode.notifSettings",
|
||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue