From 690e17611c72139157a246b78680545b306a1e0a Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sun, 5 Jul 2026 22:57:18 +0200
Subject: [PATCH 1/3] =?UTF-8?q?feat(plex):=20rich=20media=20info=20?=
=?UTF-8?q?=E2=80=94=20info=20page,=20in-player=20info=20overlay,=20IMDb?=
=?UTF-8?q?=20+=20cast=20photos?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Backend (no migration): item_detail now also returns IMDb score + id/url (from the
Plex Rating/Guid arrays), content rating, genres, director(s), studio, tagline, and
cast as {name, role, photo}. New host-whitelisted /person-image proxy serves cast
photos from Plex's public metadata CDN (keeps third-party requests off the browser).
Frontend: reusable PlexInfo component in two forms — a full info page opened from a
card's 'i' button (history subview, art backdrop, Play/Resume + watch controls) and a
lean overlay in the player toggled with 'I' (video keeps playing). Two per-user view
prefs (faint backdrop, cast row) persisted in preferences. Mark-unwatched / clear-resume
cover the watched-reset gap. i18n en/hu/de.
---
VERSION | 2 +-
backend/app/routes/plex.py | 92 +++++++-
frontend/src/components/PlexBrowse.tsx | 79 ++++++-
frontend/src/components/PlexInfo.tsx | 296 +++++++++++++++++++++++++
frontend/src/components/PlexPlayer.tsx | 40 +++-
frontend/src/i18n/locales/de/plex.json | 14 ++
frontend/src/i18n/locales/en/plex.json | 14 ++
frontend/src/i18n/locales/hu/plex.json | 14 ++
frontend/src/lib/api.ts | 15 +-
frontend/src/lib/releaseNotes.ts | 11 +
10 files changed, 568 insertions(+), 9 deletions(-)
create mode 100644 frontend/src/components/PlexInfo.tsx
diff --git a/VERSION b/VERSION
index 286d5b0..94a5fe4 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.24.0
\ No newline at end of file
+0.25.0
\ No newline at end of file
diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py
index 7a761f2..b33e79c 100644
--- a/backend/app/routes/plex.py
+++ b/backend/app/routes/plex.py
@@ -7,7 +7,9 @@ catalog sync.
"""
import logging
from datetime import datetime, timezone
+from urllib.parse import quote
+import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi.responses import FileResponse
from sqlalchemy import and_, func, or_
@@ -339,6 +341,82 @@ _PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30
+# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted)
+# so the user's browser makes no third-party request (privacy + self-host CSP friendliness).
+_PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
+
+
+def _rich_meta(meta: dict) -> dict:
+ """Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content
+ rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any
+ missing field is just omitted."""
+ # Rating: prefer the explicit IMDb entry in the Rating array, else the generic audienceRating.
+ imdb_rating = None
+ for r in meta.get("Rating") or []:
+ if str(r.get("image") or "").startswith("imdb://"):
+ try:
+ imdb_rating = round(float(r.get("value")), 1)
+ except (TypeError, ValueError):
+ pass
+ break
+ if imdb_rating is None:
+ try:
+ imdb_rating = round(float(meta.get("audienceRating") or meta.get("rating")), 1)
+ except (TypeError, ValueError):
+ imdb_rating = None
+ # IMDb id (tt…) from the Guid list → a clickable link.
+ imdb_id = None
+ for g in meta.get("Guid") or []:
+ gid = str(g.get("id") or "")
+ if gid.startswith("imdb://"):
+ imdb_id = gid.split("imdb://", 1)[1]
+ break
+ cast = []
+ for role in (meta.get("Role") or [])[:24]:
+ name = role.get("tag")
+ if not name:
+ continue
+ thumb = str(role.get("thumb") or "")
+ cast.append(
+ {
+ "name": name,
+ "role": role.get("role"),
+ "thumb": f"/api/plex/person-image?u={quote(thumb, safe='')}"
+ if thumb.startswith(_PERSON_IMG_HOST)
+ else None,
+ }
+ )
+ return {
+ "imdb_rating": imdb_rating,
+ "imdb_id": imdb_id,
+ "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
+ "content_rating": meta.get("contentRating"),
+ "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
+ "directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
+ "studio": meta.get("studio"),
+ "tagline": meta.get("tagline"),
+ "cast": cast,
+ }
+
+
+@router.get("/person-image")
+def person_image(u: str, _: User = Depends(current_user)) -> Response:
+ """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed)."""
+ if not u.startswith(_PERSON_IMG_HOST):
+ raise HTTPException(status_code=400, detail="Unsupported image host")
+ try:
+ with httpx.Client(timeout=10.0, follow_redirects=True) as c:
+ r = c.get(u)
+ r.raise_for_status()
+ except httpx.HTTPError:
+ raise HTTPException(status_code=502, detail="Image fetch failed")
+ return Response(
+ content=r.content,
+ media_type=r.headers.get("content-type", "image/jpeg"),
+ headers={"Cache-Control": "public, max-age=604800"},
+ )
+
+
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
if it.kind != "episode" or it.show_id is None:
return None, None
@@ -367,14 +445,14 @@ def item_detail(
it = _item_or_404(db, rating_key)
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
- cast: list[str] = []
+ rich: dict = {}
markers: list[dict] = []
audio_streams: list[dict] = []
subtitle_streams: list[dict] = []
try:
with PlexClient(db) as plex:
meta = plex.metadata(it.rating_key, markers=True) or {}
- cast = [r.get("tag") for r in (meta.get("Role") or []) if r.get("tag")][:12]
+ rich = _rich_meta(meta)
for m in meta.get("Marker") or []:
if m.get("type") in ("intro", "credits"):
markers.append(
@@ -419,7 +497,15 @@ def item_detail(
"playable": it.playable,
"thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art",
- "cast": cast,
+ "cast": rich.get("cast", []),
+ "imdb_rating": rich.get("imdb_rating"),
+ "imdb_id": rich.get("imdb_id"),
+ "imdb_url": rich.get("imdb_url"),
+ "content_rating": rich.get("content_rating"),
+ "genres": rich.get("genres", []),
+ "directors": rich.get("directors", []),
+ "studio": rich.get("studio"),
+ "tagline": rich.get("tagline"),
"markers": markers,
"audio_streams": audio_streams,
"subtitle_streams": subtitle_streams,
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index 96862ad..9b9024c 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -1,15 +1,20 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ArrowLeft, CheckCircle2, Play } from "lucide-react";
+import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react";
import { api, type PlexCard } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { useHistorySubview } from "../lib/history";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
+const PlexInfo = lazy(() => import("./PlexInfo"));
-type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; id: string };
+type Sub =
+ | { kind: "grid" }
+ | { kind: "show"; id: string }
+ | { kind: "player"; id: string }
+ | { kind: "info"; id: string };
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
@@ -83,6 +88,10 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
if (card.type === "show") sub.open({ kind: "show", id: card.id });
else sub.open({ kind: "player", id: card.id });
}
+ function onInfo(card: PlexCard) {
+ scrollRef.current = scroller()?.scrollTop ?? 0;
+ sub.open({ kind: "info", id: card.id });
+ }
async function toggleWatched(card: PlexCard) {
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
qc.invalidateQueries({ queryKey: ["plex-browse"] });
@@ -95,6 +104,16 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
);
}
+ if (sub.view.kind === "info") {
+ const infoId = sub.view.id;
+ return (
+ sub.open({ kind: "player", id: infoId })}
+ />
+ );
+ }
if (sub.view.kind === "show") {
return (
{items.map((c) => (
- onCard(c)} onToggleWatched={toggleWatched} />
+ onCard(c)}
+ onInfo={() => onInfo(c)}
+ onToggleWatched={toggleWatched}
+ />
))}
)}
@@ -138,10 +163,12 @@ const PLAYABLE_TINT: Record = {
function PlexPosterCard({
card,
onOpen,
+ onInfo,
onToggleWatched,
}: {
card: PlexCard;
onOpen: () => void;
+ onInfo: () => void;
onToggleWatched: (c: PlexCard) => void;
}) {
const { t } = useTranslation();
@@ -200,6 +227,19 @@ function PlexPosterCard({
)}
+ {/* Media info page (movies/episodes). */}
+ {isPlayable && (
+
+ )}
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
{card.status === "watched" && (
@@ -229,6 +269,39 @@ function PlexPosterCard({
);
}
+function PlexInfoView({
+ id,
+ onBack,
+ onPlay,
+}: {
+ id: string;
+ onBack: () => void;
+ onPlay: () => void;
+}) {
+ const { t } = useTranslation();
+ const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
+ return (
+
+
+
+
+ {q.isLoading || !q.data ? (
+
{t("plex.loading")}
+ ) : (
+
{t("plex.loading")}}>
+
+
+ )}
+
+ );
+}
+
function PlexShowView({
showId,
onBack,
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
new file mode 100644
index 0000000..2b8f71e
--- /dev/null
+++ b/frontend/src/components/PlexInfo.tsx
@@ -0,0 +1,296 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+ Check,
+ ExternalLink,
+ Play,
+ RotateCcw,
+ SlidersHorizontal,
+ Star,
+ X,
+} from "lucide-react";
+import { api, 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
+// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
+// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
+// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
+
+type Props = {
+ detail: PlexItemDetail;
+ variant: "page" | "overlay";
+ onPlay?: () => void; // page: start/resume playback
+ onClose?: () => void; // overlay: dismiss
+ onStateChange?: () => void; // after a watch-state change, let the opener refresh
+};
+
+function fmtDur(s?: number | null): string {
+ if (!s) return "";
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ return h ? `${h}h ${m}m` : `${m}m`;
+}
+
+export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange }: Props) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+
+ // Personal display prefs (default ON). Stored per-user; the backend merges any pref key.
+ const prefs = (qc.getQueryData<{ preferences?: Record }>(["me"])?.preferences ??
+ {}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean };
+ const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
+ const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
+ const [customizing, setCustomizing] = useState(false);
+ const savePref = (patch: Record) => {
+ api.savePrefs(patch).catch(() => {});
+ qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) =>
+ m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
+ );
+ };
+
+ const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
+ const setState = async (status: "new" | "watched") => {
+ await api.plexSetState(detail.id, status).catch(() => {});
+ qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
+ qc.invalidateQueries({ queryKey: ["plex-browse"] });
+ qc.invalidateQueries({ queryKey: ["plex-show"] });
+ 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";
+
+ return (
+
+ {/* Faint art backdrop (page only, toggleable). */}
+ {!overlay && artBg && detail.art && (
+
+

+
+
+ )}
+
+
+ {/* Header: close (overlay) / customize */}
+
+
+
+ {detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
+
+ {detail.kind === "episode" && (
+
+ S{detail.season_number}·E{detail.episode_number} — {detail.title}
+
+ )}
+
+
+
+ {customizing && (
+
+
{
+ setArtBg((v) => !v);
+ savePref({ plexInfoArtBg: !artBg });
+ }}
+ />
+ {
+ setShowCast((v) => !v);
+ savePref({ plexInfoCast: !showCast });
+ }}
+ />
+
+ )}
+
+ {overlay && onClose && (
+
+ )}
+
+
+
+ {/* Poster */}
+

+
+
+ {/* Meta line + IMDb rating/link */}
+
+
+ {detail.tagline && (
+
+ {detail.tagline}
+
+ )}
+
+ {detail.genres.length > 0 && (
+
+ {detail.genres.map((g) => (
+
+ {g}
+
+ ))}
+
+ )}
+
+ {detail.directors.length > 0 && (
+
+ {t("plex.info.director")}: {detail.directors.join(", ")}
+
+ )}
+
+ {detail.summary && (
+
+ {detail.summary}
+
+ )}
+
+ {/* Page actions: Play/Resume + watch controls */}
+ {!overlay && (
+
+ {onPlay && (
+
+ )}
+ {detail.status === "watched" ? (
+
+ ) : (
+
+ )}
+ {inProgress && (
+
+ )}
+
+ )}
+
+
+
+ {/* Cast row (toggleable), circular photos. */}
+ {cast.length > 0 && (
+
+
+ {t("plex.info.cast")}
+
+
+ {cast.map((c, i) => (
+
+
+ {c.thumb ? (
+

+ ) : (
+
+ {c.name.charAt(0)}
+
+ )}
+
+
+ {c.name}
+
+ {c.role && (
+
+ {c.role}
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx
index 28f62ed..0df196c 100644
--- a/frontend/src/components/PlexPlayer.tsx
+++ b/frontend/src/components/PlexPlayer.tsx
@@ -1,4 +1,4 @@
-import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
+import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
@@ -6,6 +6,7 @@ import {
ArrowLeft,
Clock,
Download,
+ Info,
Keyboard,
Maximize,
Minimize,
@@ -22,6 +23,9 @@ import {
} from "lucide-react";
import { api, type PlexMarker } from "../lib/api";
+// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
+const PlexInfo = lazy(() => import("./PlexInfo"));
+
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
// (native
}>
-
+
)}
diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx
index 2b8f71e..188425e 100644
--- a/frontend/src/components/PlexInfo.tsx
+++ b/frontend/src/components/PlexInfo.tsx
@@ -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) => 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 {children};
+ return (
+
+ );
+}
+
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 (
@@ -141,27 +158,50 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
/>
- {/* Meta line + IMDb rating/link */}
+ {/* Meta line + IMDb rating/link (each value can set the matching filter). */}
- {meta.map((m, i) => (
-
- {m}
-
- ))}
- {detail.imdb_rating != null && (
-
onFilter({ yearMin: detail.year, yearMax: detail.year }) : undefined}
>
-
- {detail.imdb_rating.toFixed(1)}
- {detail.imdb_url && }
-
+ {detail.year}
+
+ )}
+ {detail.content_rating && (
+
onFilter({ contentRatings: [detail.content_rating!] }) : undefined}
+ >
+ {detail.content_rating}
+
+ )}
+ {detail.duration_seconds ?
{fmtDur(detail.duration_seconds)} : null}
+ {detail.studio && (
+
onFilter({ studio: detail.studio! }) : undefined}
+ >
+ {detail.studio}
+
+ )}
+ {detail.imdb_rating != null && (
+
+
+ {detail.imdb_url && (
+
+
+
+ )}
+
)}
@@ -174,19 +214,31 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
{detail.genres.length > 0 && (
{detail.genres.map((g) => (
- onFilter({ genres: [g] }) : undefined}
>
{g}
-
+
))}
)}
{detail.directors.length > 0 && (
-
- {t("plex.info.director")}: {detail.directors.join(", ")}
+
+ {t("plex.info.director")}:{" "}
+ {detail.directors.map((d, i) => (
+
+ onFilter({ director: d }) : undefined}
+ >
+ {d}
+
+ {i < detail.directors.length - 1 ? ", " : ""}
+
+ ))}
)}
@@ -246,29 +298,51 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
{t("plex.info.cast")}
- {cast.map((c, i) => (
-
-
- {c.thumb ? (
-

- ) : (
-
- {c.name.charAt(0)}
+ {cast.map((c, i) => {
+ const inner = (
+ <>
+
+ {c.thumb ? (
+

+ ) : (
+
+ {c.name.charAt(0)}
+
+ )}
+
+
+ {c.name}
+
+ {c.role && (
+
+ {c.role}
)}
+ >
+ );
+ return onFilter ? (
+
+ ) : (
+
+ {inner}
-
- {c.name}
-
- {c.role && (
-
- {c.role}
-
- )}
-
- ))}
+ );
+ })}
)}
diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx
index 9d14f34..cf1aee0 100644
--- a/frontend/src/components/PlexSidebar.tsx
+++ b/frontend/src/components/PlexSidebar.tsx
@@ -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
) => 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 (
);
}
+
+function Section({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ );
+}
+
+function ChipRow({ children }: { children: ReactNode }) {
+ return {children}
;
+}
+
+function Chip({
+ active,
+ onClick,
+ children,
+}: {
+ active: boolean;
+ onClick: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
+ return (
+
+ {label}
+
+
+ );
+}
+
+function NumberInput({
+ value,
+ placeholder,
+ onChange,
+}: {
+ value: number | null;
+ placeholder?: number;
+ onChange: (v: number | null) => void;
+}) {
+ return (
+ {
+ 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"
+ />
+ );
+}
diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json
index 473eaa9..6ed5d61 100644
--- a/frontend/src/i18n/locales/de/plex.json
+++ b/frontend/src/i18n/locales/de/plex.json
@@ -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",
diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json
index 2030ba1..0378749 100644
--- a/frontend/src/i18n/locales/en/plex.json
+++ b/frontend/src/i18n/locales/en/plex.json
@@ -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",
diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json
index c3c60d0..6961d49 100644
--- a/frontend/src/i18n/locales/hu/plex.json
+++ b/frontend/src/i18n/locales/hu/plex.json
@@ -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",
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 2d56c16..06ff88e 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -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 => {
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 =>
+ req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
plexShow: (id: string): Promise =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
plexItem: (id: string): Promise =>
diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts
index bb579ac..d04ee34 100644
--- a/frontend/src/lib/releaseNotes.ts
+++ b/frontend/src/lib/releaseNotes.ts
@@ -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",
diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts
index 18c613b..3e3de01 100644
--- a/frontend/src/lib/storage.ts
+++ b/frontend/src/lib/storage.ts
@@ -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",
From 1982cfa7b983febcb928487786eee554a7b24e4d Mon Sep 17 00:00:00 2001
From: npeter83
Date: Mon, 6 Jul 2026 00:15:31 +0200
Subject: [PATCH 3/3] feat(plex): sort direction, genre any/all, multi-person
filters; fix back-nav + player stop
UAT follow-ups on the Plex filter epic:
- Sort now has an asc/desc toggle (sort_dir), applied to any sort field.
- Genre multi-select gains an Any/All mode (genre_mode: OR vs AND containment).
- Director/actor/studio become multi-value: people AND (titles featuring all selected),
studios OR; clicking them on the info page stacks (unions) instead of replacing, and
the sidebar shows each as a removable 'Active' chip.
- fix(history): clicking a metadata filter on the info page now pushes a fresh grid
entry instead of history.back(), so browser Back returns to the info page rather than
leaving the Plex module.
- fix(player): fully tear down the