From eefd7e3abd03c39281531ae625683e07a62307d3 Mon Sep 17 00:00:00 2001
From: npeter83
Date: Sun, 5 Jul 2026 23:45:55 +0200
Subject: [PATCH] feat(plex): expanded metadata filters, clickable info page,
image disk cache
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
VERSION | 2 +-
.../alembic/versions/0045_plex_filter_meta.py | 62 ++++
backend/app/models.py | 15 +-
backend/app/plex/sync.py | 31 ++
backend/app/routes/plex.py | 174 +++++++++-
frontend/src/App.tsx | 25 +-
frontend/src/components/PlexBrowse.tsx | 34 +-
frontend/src/components/PlexInfo.tsx | 180 ++++++++---
frontend/src/components/PlexSidebar.tsx | 304 +++++++++++++++---
frontend/src/i18n/locales/de/plex.json | 24 +-
frontend/src/i18n/locales/en/plex.json | 24 +-
frontend/src/i18n/locales/hu/plex.json | 24 +-
frontend/src/lib/api.ts | 54 ++++
frontend/src/lib/releaseNotes.ts | 10 +
frontend/src/lib/storage.ts | 1 +
15 files changed, 842 insertions(+), 122 deletions(-)
create mode 100644 backend/alembic/versions/0045_plex_filter_meta.py
diff --git a/VERSION b/VERSION
index 94a5fe4..e93b647 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.25.0
\ No newline at end of file
+0.26.0
\ No newline at end of file
diff --git a/backend/alembic/versions/0045_plex_filter_meta.py b/backend/alembic/versions/0045_plex_filter_meta.py
new file mode 100644
index 0000000..f47c03a
--- /dev/null
+++ b/backend/alembic/versions/0045_plex_filter_meta.py
@@ -0,0 +1,62 @@
+"""Plex filterable/orderable metadata
+
+Revision ID: 0045_plex_filter_meta
+Revises: 0044_plex_catalog
+Create Date: 2026-07-05
+
+Adds the metadata the expanded Plex filter sidebar filters + orders on, mirrored cheaply from the
+Plex section listing (no per-item API calls): rating (audienceRating ~ IMDb), content rating, studio,
+release date, and GIN-indexed genre / director / cast arrays. These also double as the content
+signals a future watch-habit recommender would use.
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects import postgresql
+
+revision: str = "0045_plex_filter_meta"
+down_revision: Union[str, None] = "0044_plex_catalog"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("plex_items", sa.Column("rating", sa.Float(), nullable=True))
+ op.add_column("plex_items", sa.Column("content_rating", sa.String(length=16), nullable=True))
+ op.add_column("plex_items", sa.Column("studio", sa.String(length=255), nullable=True))
+ op.add_column("plex_items", sa.Column("originally_available_at", sa.Date(), nullable=True))
+ op.add_column("plex_items", sa.Column("genres", postgresql.JSONB(), nullable=True))
+ op.add_column("plex_items", sa.Column("directors", postgresql.JSONB(), nullable=True))
+ op.add_column("plex_items", sa.Column("cast_names", postgresql.JSONB(), nullable=True))
+
+ op.create_index("ix_plex_items_rating", "plex_items", ["rating"])
+ op.create_index("ix_plex_items_content_rating", "plex_items", ["content_rating"])
+ op.create_index("ix_plex_items_studio", "plex_items", ["studio"])
+ op.create_index("ix_plex_items_originally_available_at", "plex_items", ["originally_available_at"])
+ op.create_index("ix_plex_items_genres_gin", "plex_items", ["genres"], postgresql_using="gin")
+ op.create_index("ix_plex_items_directors_gin", "plex_items", ["directors"], postgresql_using="gin")
+ op.create_index("ix_plex_items_cast_gin", "plex_items", ["cast_names"], postgresql_using="gin")
+
+
+def downgrade() -> None:
+ for ix in (
+ "ix_plex_items_cast_gin",
+ "ix_plex_items_directors_gin",
+ "ix_plex_items_genres_gin",
+ "ix_plex_items_originally_available_at",
+ "ix_plex_items_studio",
+ "ix_plex_items_content_rating",
+ "ix_plex_items_rating",
+ ):
+ op.drop_index(ix, table_name="plex_items")
+ for col in (
+ "cast_names",
+ "directors",
+ "genres",
+ "originally_available_at",
+ "studio",
+ "content_rating",
+ "rating",
+ ):
+ op.drop_column("plex_items", col)
diff --git a/backend/app/models.py b/backend/app/models.py
index fe33961..8c87b80 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -16,7 +16,7 @@ from sqlalchemy import (
UniqueConstraint,
func,
)
-from sqlalchemy.dialects.postgresql import TSVECTOR
+from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
@@ -995,6 +995,9 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
__tablename__ = "plex_items"
__table_args__ = (
Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"),
+ Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
+ Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
+ Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
)
id: Mapped[int] = mapped_column(primary_key=True)
@@ -1031,6 +1034,16 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
markers: Mapped[list | None] = mapped_column(JSON)
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
+ # Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
+ # `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
+ # filters (GIN-indexed) and double as content signals for a future watch-habit recommender.
+ rating: Mapped[float | None] = mapped_column(Float, index=True)
+ content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
+ studio: Mapped[str | None] = mapped_column(String(255), index=True)
+ originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True)
+ genres: Mapped[list | None] = mapped_column(JSONB)
+ directors: Mapped[list | None] = mapped_column(JSONB)
+ cast_names: Mapped[list | None] = mapped_column(JSONB)
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py
index ea1b1c1..e682301 100644
--- a/backend/app/plex/sync.py
+++ b/backend/app/plex/sync.py
@@ -56,6 +56,29 @@ def _epoch(v) -> datetime | None:
return None
+def _date(v):
+ """Parse a Plex 'YYYY-MM-DD' string (originallyAvailableAt) → date, or None."""
+ try:
+ return datetime.strptime(v, "%Y-%m-%d").date() if v else None
+ except (TypeError, ValueError):
+ return None
+
+
+def _tags(meta: dict, key: str, limit: int | None = None) -> list | None:
+ """The .tag values of a Plex child-tag array (Genre/Director/Role) present in the listing."""
+ vals = [t.get("tag") for t in (meta.get(key) or []) if t.get("tag")]
+ if limit:
+ vals = vals[:limit]
+ return vals or None
+
+
+def _rating(meta: dict) -> float | None:
+ try:
+ return float(meta.get("audienceRating") or meta.get("rating"))
+ except (TypeError, ValueError):
+ return None
+
+
def _media_facts(meta: dict) -> dict:
media = meta.get("Media") or []
if not media:
@@ -152,6 +175,14 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
row.thumb_key = meta.get("thumb")
row.art_key = meta.get("art") or meta.get("grandparentArt")
row.added_at = _epoch(meta.get("addedAt"))
+ # Filterable / orderable extras — all present in the cheap section listing.
+ row.rating = _rating(meta)
+ row.content_rating = (meta.get("contentRating") or None)
+ row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
+ row.originally_available_at = _date(meta.get("originallyAvailableAt"))
+ row.genres = _tags(meta, "Genre")
+ row.directors = _tags(meta, "Director")
+ row.cast_names = _tags(meta, "Role", limit=20)
mf = _media_facts(meta)
row.file_path = mf.get("file_path")
row.part_key = mf.get("part_key")
diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py
index b33e79c..9a5c982 100644
--- a/backend/app/routes/plex.py
+++ b/backend/app/routes/plex.py
@@ -5,18 +5,21 @@ pure-Siftlode included) — the module is an access layer to the Plex library WI
plex.tv account, and playback is a local file. Admin endpoints test the connection and run the
catalog sync.
"""
+import hashlib
import logging
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
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_
+from sqlalchemy import and_, func, or_, text
from sqlalchemy.orm import Session, aliased
from app import sysconfig
from app.auth import current_user
+from app.config import settings
from app.db import get_db
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
from app.plex import paths as plex_paths
@@ -169,6 +172,18 @@ def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
}
+def _csv(v: str | None) -> list[str]:
+ return [s.strip() for s in v.split(",") if s.strip()] if v else []
+
+
+_ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365}
+
+
+def _added_cutoff(within: str | None) -> datetime | None:
+ days = _ADDED_WINDOWS.get(within or "")
+ return datetime.now(timezone.utc) - timedelta(days=days) if days else None
+
+
@router.get("/browse")
def browse(
library: str,
@@ -177,6 +192,17 @@ def browse(
show: str = "all",
offset: int = 0,
limit: int = Query(default=40, ge=1, le=100),
+ genres: str | None = None,
+ content_ratings: str | None = None,
+ year_min: int | None = None,
+ year_max: int | None = None,
+ rating_min: float | None = None,
+ duration_min: int | None = None,
+ duration_max: int | None = None,
+ added_within: str | None = None,
+ director: str | None = None,
+ actor: str | None = None,
+ studio: str | None = None,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@@ -203,6 +229,32 @@ def browse(
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
else: # all — hide only the explicitly hidden
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
+ # --- Metadata filters (movie libraries; @> containment is GIN-indexed) ---
+ genre_any = _csv(genres)
+ if genre_any:
+ query = query.filter(or_(*[PlexItem.genres.contains([g]) for g in genre_any]))
+ crs = _csv(content_ratings)
+ if crs:
+ query = query.filter(PlexItem.content_rating.in_(crs))
+ if year_min is not None:
+ query = query.filter(PlexItem.year >= year_min)
+ if year_max is not None:
+ query = query.filter(PlexItem.year <= year_max)
+ if rating_min is not None:
+ query = query.filter(PlexItem.rating >= rating_min)
+ if duration_min is not None:
+ query = query.filter(PlexItem.duration_s >= duration_min)
+ if duration_max is not None:
+ query = query.filter(PlexItem.duration_s <= duration_max)
+ cutoff = _added_cutoff(added_within)
+ if cutoff is not None:
+ query = query.filter(PlexItem.added_at >= cutoff)
+ if director:
+ query = query.filter(PlexItem.directors.contains([director]))
+ if actor:
+ query = query.filter(PlexItem.cast_names.contains([actor]))
+ if studio:
+ query = query.filter(PlexItem.studio == studio)
else:
query = query.filter(PlexShow.library_id == lib.id)
@@ -218,7 +270,15 @@ def browse(
order = [func.ts_rank(model.search_vector, tsq).desc()]
elif sort == "title":
order = [func.lower(model.title).asc()]
- else: # newest first
+ elif sort == "year" and model is PlexItem:
+ order = [PlexItem.year.desc().nullslast()]
+ elif sort == "rating" and model is PlexItem:
+ order = [PlexItem.rating.desc().nullslast()]
+ elif sort == "duration" and model is PlexItem:
+ order = [PlexItem.duration_s.desc().nullslast()]
+ elif sort == "release" and model is PlexItem:
+ order = [PlexItem.originally_available_at.desc().nullslast()]
+ else: # added — newest first
order = [model.added_at.desc().nullslast()]
order.append(model.id.desc())
@@ -239,6 +299,53 @@ def browse(
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
+@router.get("/facets")
+def facets(
+ library: str,
+ user: User = Depends(current_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ """Available filter values for a movie library — genres + content ratings (with counts) and the
+ year/rating/duration bounds — so the sidebar only offers what the library actually contains."""
+ empty = {
+ "genres": [],
+ "content_ratings": [],
+ "year_min": None,
+ "year_max": None,
+ "rating_max": None,
+ "duration_min": None,
+ "duration_max": None,
+ }
+ lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
+ if lib is None or lib.kind != "movie":
+ return empty
+ base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'"
+ genres = db.execute(
+ text(
+ f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x "
+ "GROUP BY g ORDER BY c DESC, g"
+ ),
+ {"lib": lib.id},
+ ).all()
+ crs = db.execute(
+ text(f"SELECT content_rating, count(*) AS c {base} AND content_rating IS NOT NULL GROUP BY content_rating ORDER BY c DESC"),
+ {"lib": lib.id},
+ ).all()
+ b = db.execute(
+ text(f"SELECT min(year), max(year), max(rating), min(duration_s), max(duration_s) {base}"),
+ {"lib": lib.id},
+ ).first()
+ return {
+ "genres": [{"value": g, "count": c} for g, c in genres],
+ "content_ratings": [{"value": cr, "count": c} for cr, c in crs],
+ "year_min": b[0],
+ "year_max": b[1],
+ "rating_max": float(b[2]) if b[2] is not None else None,
+ "duration_min": b[3],
+ "duration_max": b[4],
+ }
+
+
@router.get("/show/{rating_key}")
def show_detail(
rating_key: str,
@@ -304,6 +411,42 @@ def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
return None
+# --- Thin on-disk image cache -----------------------------------------------------------------
+# Posters/art/cast-photos are proxied on demand; without a cache every scroll re-hits Plex over the
+# LAN (janky). We cache the fetched bytes on local disk keyed by a stable id, so repeat views (and
+# other users) serve instantly from disk. On-demand + bounded by the library size; no eviction (a
+# few hundred MB at most for a big library — acceptable, matching the download disk trade-offs).
+_IMG_CACHE = Path(settings.download_root) / ".plex-img-cache"
+_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
+_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
+
+
+def _img_cache_read(key: str) -> tuple[bytes, str] | None:
+ for ext, ct in _EXT_CT:
+ p = _IMG_CACHE / f"{key}.{ext}"
+ try:
+ if p.exists() and p.stat().st_size > 0:
+ return p.read_bytes(), ct
+ except OSError:
+ pass
+ return None
+
+
+def _img_cache_write(key: str, data: bytes, ct: str) -> None:
+ ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg")
+ try:
+ _IMG_CACHE.mkdir(parents=True, exist_ok=True)
+ tmp = _IMG_CACHE / f"{key}.{ext}.tmp"
+ tmp.write_bytes(data)
+ tmp.replace(_IMG_CACHE / f"{key}.{ext}") # atomic publish
+ except OSError:
+ pass # cache is best-effort
+
+
+def _img_response(data: bytes, ct: str) -> Response:
+ return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
+
+
@router.get("/image/{rating_key}")
def image(
rating_key: str,
@@ -311,8 +454,14 @@ def image(
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> Response:
- """Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication)."""
- key = _image_key(db, str(rating_key), "art" if variant == "art" else "thumb")
+ """Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A
+ thin on-disk cache serves repeat views without re-hitting Plex."""
+ v = "art" if variant == "art" else "thumb"
+ cache_key = f"item_{rating_key}_{v}"
+ hit = _img_cache_read(cache_key)
+ if hit:
+ return _img_response(*hit)
+ key = _image_key(db, str(rating_key), v)
if not key:
raise HTTPException(status_code=404, detail="No image")
try:
@@ -322,7 +471,8 @@ def image(
raise HTTPException(status_code=400, detail=str(e))
except PlexError as e:
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
- return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
+ _img_cache_write(cache_key, data, ct)
+ return _img_response(data, ct)
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
@@ -404,17 +554,19 @@ 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")
+ cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
+ hit = _img_cache_read(cache_key)
+ if hit:
+ return _img_response(*hit)
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"},
- )
+ ct = r.headers.get("content-type", "image/jpeg")
+ _img_cache_write(cache_key, r.content, ct)
+ return _img_response(r.content, ct)
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index c441be1..0db6c3f 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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(() => {
+ 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 ? (
-
+
) : (
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) => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
@@ -295,7 +317,7 @@ function PlexInfoView({
{t("plex.loading")}
) : (
{t("plex.loading")}
}>
-
+
)}
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",