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 ( +
+
{label}
+ {children} +
+ ); +} + +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",