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

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

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

View file

@ -1 +1 @@
0.25.0
0.26.0

View file

@ -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)

View file

@ -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(

View file

@ -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")

View file

@ -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]:

View file

@ -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}

View file

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

View file

@ -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>
)}

View file

@ -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"
/>
);
}

View file

@ -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": "90120 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",

View file

@ -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": "90120m", "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",

View file

@ -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": "90120 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",

View file

@ -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> =>

View file

@ -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",

View file

@ -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",