diff --git a/VERSION b/VERSION index 286d5b0..e93b647 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.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 7a761f2..2ed696f 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -5,16 +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 @@ -167,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, @@ -175,6 +192,19 @@ def browse( show: str = "all", offset: int = 0, limit: int = Query(default=40, ge=1, le=100), + genres: str | None = None, + genre_mode: str = "any", + 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, + directors: str | None = None, + actors: str | None = None, + studios: str | None = None, + sort_dir: str = "desc", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -201,6 +231,34 @@ 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) --- + gsel = _csv(genres) + if gsel: + conds = [PlexItem.genres.contains([g]) for g in gsel] + query = query.filter(and_(*conds) if genre_mode == "all" else or_(*conds)) + 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) + for d in _csv(directors): # AND — titles this whole set directed + query = query.filter(PlexItem.directors.contains([d])) + for a in _csv(actors): # AND — titles featuring all these people + query = query.filter(PlexItem.cast_names.contains([a])) + stds = _csv(studios) + if stds: # OR — a title has one studio + query = query.filter(PlexItem.studio.in_(stds)) else: query = query.filter(PlexShow.library_id == lib.id) @@ -214,10 +272,21 @@ def browse( if tsq is not None: order = [func.ts_rank(model.search_vector, tsq).desc()] - elif sort == "title": - order = [func.lower(model.title).asc()] - else: # newest first - order = [model.added_at.desc().nullslast()] + else: + if sort == "title": + col = func.lower(model.title) + elif sort == "year" and model is PlexItem: + col = PlexItem.year + elif sort == "rating" and model is PlexItem: + col = PlexItem.rating + elif sort == "duration" and model is PlexItem: + col = PlexItem.duration_s + elif sort == "release" and model is PlexItem: + col = PlexItem.originally_available_at + else: # added + col = model.added_at + asc = sort_dir == "asc" + order = [(col.asc() if asc else col.desc()).nullslast()] order.append(model.id.desc()) rows = query.order_by(*order).offset(offset).limit(limit).all() @@ -237,6 +306,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, @@ -302,6 +418,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, @@ -309,8 +461,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: @@ -320,7 +478,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) ---------------------------- @@ -339,6 +498,84 @@ _PROGRESS_MIN_S = 5 _FINISH_MARGIN_S = 30 +# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted) +# so the user's browser makes no third-party request (privacy + self-host CSP friendliness). +_PERSON_IMG_HOST = "https://metadata-static.plex.tv/" + + +def _rich_meta(meta: dict) -> dict: + """Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content + rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any + missing field is just omitted.""" + # Rating: prefer the explicit IMDb entry in the Rating array, else the generic audienceRating. + imdb_rating = None + for r in meta.get("Rating") or []: + if str(r.get("image") or "").startswith("imdb://"): + try: + imdb_rating = round(float(r.get("value")), 1) + except (TypeError, ValueError): + pass + break + if imdb_rating is None: + try: + imdb_rating = round(float(meta.get("audienceRating") or meta.get("rating")), 1) + except (TypeError, ValueError): + imdb_rating = None + # IMDb id (tt…) from the Guid list → a clickable link. + imdb_id = None + for g in meta.get("Guid") or []: + gid = str(g.get("id") or "") + if gid.startswith("imdb://"): + imdb_id = gid.split("imdb://", 1)[1] + break + cast = [] + for role in (meta.get("Role") or [])[:24]: + name = role.get("tag") + if not name: + continue + thumb = str(role.get("thumb") or "") + cast.append( + { + "name": name, + "role": role.get("role"), + "thumb": f"/api/plex/person-image?u={quote(thumb, safe='')}" + if thumb.startswith(_PERSON_IMG_HOST) + else None, + } + ) + return { + "imdb_rating": imdb_rating, + "imdb_id": imdb_id, + "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, + "content_rating": meta.get("contentRating"), + "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], + "directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4], + "studio": meta.get("studio"), + "tagline": meta.get("tagline"), + "cast": cast, + } + + +@router.get("/person-image") +def person_image(u: str, _: User = Depends(current_user)) -> Response: + """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).""" + if not u.startswith(_PERSON_IMG_HOST): + raise HTTPException(status_code=400, detail="Unsupported image host") + 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") + 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]: if it.kind != "episode" or it.show_id is None: return None, None @@ -367,14 +604,14 @@ def item_detail( it = _item_or_404(db, rating_key) st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() - cast: list[str] = [] + rich: dict = {} markers: list[dict] = [] audio_streams: list[dict] = [] subtitle_streams: list[dict] = [] try: with PlexClient(db) as plex: meta = plex.metadata(it.rating_key, markers=True) or {} - cast = [r.get("tag") for r in (meta.get("Role") or []) if r.get("tag")][:12] + rich = _rich_meta(meta) for m in meta.get("Marker") or []: if m.get("type") in ("intro", "credits"): markers.append( @@ -419,7 +656,15 @@ def item_detail( "playable": it.playable, "thumb": f"/api/plex/image/{it.rating_key}", "art": f"/api/plex/image/{it.rating_key}?variant=art", - "cast": cast, + "cast": rich.get("cast", []), + "imdb_rating": rich.get("imdb_rating"), + "imdb_id": rich.get("imdb_id"), + "imdb_url": rich.get("imdb_url"), + "content_rating": rich.get("content_rating"), + "genres": rich.get("genres", []), + "directors": rich.get("directors", []), + "studio": rich.get("studio"), + "tagline": rich.get("tagline"), "markers": markers, "audio_streams": audio_streams, "subtitle_streams": subtitle_streams, diff --git a/frontend/src/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 ? ( - + ) : ( import("./PlexPlayer")); +const PlexInfo = lazy(() => import("./PlexInfo")); -type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; id: string }; +type Sub = + | { kind: "grid" } + | { kind: "show"; id: string } + | { kind: "player"; id: string } + | { kind: "info"; id: string }; // The Plex module's content area: browse/search the mirrored library as poster cards, drill from a // show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the @@ -17,7 +22,14 @@ type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; i // (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; @@ -28,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); @@ -48,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; @@ -83,6 +103,10 @@ export default function PlexBrowse({ q, library, show, sort }: Props) { if (card.type === "show") sub.open({ kind: "show", id: card.id }); else sub.open({ kind: "player", id: card.id }); } + function onInfo(card: PlexCard) { + scrollRef.current = scroller()?.scrollTop ?? 0; + sub.open({ kind: "info", id: card.id }); + } async function toggleWatched(card: PlexCard) { await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched"); qc.invalidateQueries({ queryKey: ["plex-browse"] }); @@ -95,6 +119,33 @@ export default function PlexBrowse({ q, library, show, sort }: Props) { ); } + if (sub.view.kind === "info") { + const infoId = sub.view.id; + return ( + sub.open({ kind: "player", id: infoId })} + onFilter={(patch) => { + // Clicking a metadata chip sets that filter and shows the filtered grid. Array filters + // (genres/people/studios) UNION with what's already set (so you can stack people); scalars + // replace. We push a fresh grid entry (not history.back) so browser Back returns to this + // info page instead of leaving the Plex module. + const merged = { ...filters } as unknown as Record; + for (const [k, v] of Object.entries(patch)) { + const cur = merged[k]; + if (Array.isArray(v) && Array.isArray(cur)) { + merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])])); + } else { + merged[k] = v; + } + } + setFilters(merged as unknown as PlexFilters); + sub.open({ kind: "grid" }); + }} + /> + ); + } if (sub.view.kind === "show") { return ( {items.map((c) => ( - onCard(c)} onToggleWatched={toggleWatched} /> + onCard(c)} + onInfo={() => onInfo(c)} + onToggleWatched={toggleWatched} + /> ))} )} @@ -138,10 +195,12 @@ const PLAYABLE_TINT: Record = { function PlexPosterCard({ card, onOpen, + onInfo, onToggleWatched, }: { card: PlexCard; onOpen: () => void; + onInfo: () => void; onToggleWatched: (c: PlexCard) => void; }) { const { t } = useTranslation(); @@ -200,6 +259,19 @@ function PlexPosterCard({ )} + {/* Media info page (movies/episodes). */} + {isPlayable && ( + + )} {/* Watched badge when not hovering (the toggle replaces it on hover). */} {card.status === "watched" && ( @@ -229,6 +301,41 @@ function PlexPosterCard({ ); } +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) }); + return ( +
+
+ +
+ {q.isLoading || !q.data ? ( +

{t("plex.loading")}

+ ) : ( + {t("plex.loading")}

}> + +
+ )} +
+ ); +} + function PlexShowView({ showId, onBack, diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx new file mode 100644 index 0000000..4adf81b --- /dev/null +++ b/frontend/src/components/PlexInfo.tsx @@ -0,0 +1,370 @@ +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Check, + ExternalLink, + Play, + RotateCcw, + SlidersHorizontal, + Star, + X, +} from "lucide-react"; +import { api, type 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 +// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay +// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable +// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row. + +type Props = { + detail: PlexItemDetail; + variant: "page" | "overlay"; + onPlay?: () => void; // page: start/resume playback + onClose?: () => void; // overlay: dismiss + onStateChange?: () => void; // after a watch-state change, let the opener refresh + // 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); + const m = Math.floor((s % 3600) / 60); + return h ? `${h}h ${m}m` : `${m}m`; +} + +export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange, onFilter }: Props) { + const { t } = useTranslation(); + const qc = useQueryClient(); + + // Personal display prefs (default ON). Stored per-user; the backend merges any pref key. + const prefs = (qc.getQueryData<{ preferences?: Record }>(["me"])?.preferences ?? + {}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean }; + const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false); + const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false); + const [customizing, setCustomizing] = useState(false); + const savePref = (patch: Record) => { + api.savePrefs(patch).catch(() => {}); + qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => + m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, + ); + }; + + const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched"; + const setState = async (status: "new" | "watched") => { + await api.plexSetState(detail.id, status).catch(() => {}); + qc.invalidateQueries({ queryKey: ["plex-item", detail.id] }); + qc.invalidateQueries({ queryKey: ["plex-browse"] }); + qc.invalidateQueries({ queryKey: ["plex-show"] }); + onStateChange?.(); + }; + + const cast = showCast ? detail.cast : []; + const overlay = variant === "overlay"; + const mutedCls = overlay ? "text-white/70" : "text-muted"; + + return ( +
+ {/* Faint art backdrop (page only, toggleable). */} + {!overlay && artBg && detail.art && ( +
+ +
+
+ )} + +
+ {/* Header: close (overlay) / customize */} +
+
+

+ {detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title} +

+ {detail.kind === "episode" && ( +

+ S{detail.season_number}·E{detail.episode_number} — {detail.title} +

+ )} +
+
+ + {customizing && ( +
+ { + setArtBg((v) => !v); + savePref({ plexInfoArtBg: !artBg }); + }} + /> + { + setShowCast((v) => !v); + savePref({ plexInfoCast: !showCast }); + }} + /> +
+ )} +
+ {overlay && onClose && ( + + )} +
+ +
+ {/* Poster */} + + +
+ {/* Meta line + IMDb rating/link (each value can set the matching filter). */} +
+ {detail.year != null && ( + onFilter({ yearMin: detail.year, yearMax: detail.year }) : undefined} + > + {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({ studios: [detail.studio!] }) : undefined} + > + {detail.studio} + + )} + {detail.imdb_rating != null && ( + + + {detail.imdb_url && ( + + + + )} + + )} +
+ + {detail.tagline && ( +

+ {detail.tagline} +

+ )} + + {detail.genres.length > 0 && ( +
+ {detail.genres.map((g) => ( + onFilter({ genres: [g] }) : undefined} + > + {g} + + ))} +
+ )} + + {detail.directors.length > 0 && ( +

+ {t("plex.info.director")}:{" "} + {detail.directors.map((d, i) => ( + + onFilter({ directors: [d] }) : undefined} + > + {d} + + {i < detail.directors.length - 1 ? ", " : ""} + + ))} +

+ )} + + {detail.summary && ( +

+ {detail.summary} +

+ )} + + {/* Page actions: Play/Resume + watch controls */} + {!overlay && ( +
+ {onPlay && ( + + )} + {detail.status === "watched" ? ( + + ) : ( + + )} + {inProgress && ( + + )} +
+ )} +
+
+ + {/* Cast row (toggleable), circular photos. */} + {cast.length > 0 && ( +
+

+ {t("plex.info.cast")} +

+
+ {cast.map((c, i) => { + const inner = ( + <> +
+ {c.thumb ? ( + + ) : ( +
+ {c.name.charAt(0)} +
+ )} +
+
+ {c.name} +
+ {c.role && ( +
+ {c.role} +
+ )} + + ); + return onFilter ? ( + + ) : ( +
+ {inner} +
+ ); + })} +
+
+ )} +
+
+ ); +} + +function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) { + return ( + + ); +} diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 28f62ed..e3f7e3e 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import Hls from "hls.js"; @@ -6,6 +6,7 @@ import { ArrowLeft, Clock, Download, + Info, Keyboard, Maximize, Minimize, @@ -22,6 +23,9 @@ import { } from "lucide-react"; import { api, type PlexMarker } from "../lib/api"; +// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page. +const PlexInfo = lazy(() => import("./PlexInfo")); + // The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw // (native
)} + setInfoOpen((v) => !v)}> + + setHelpOpen((v) => !v)}> @@ -686,6 +728,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) { [["↑", "↓"], "plex.player.keys.volume"], [["M"], "plex.player.keys.mute"], [["F"], "plex.player.keys.fullscreen"], + [["I"], "plex.player.keys.info"], [["⌫", "Esc"], "plex.player.keys.back"], [["H"], "plex.player.keys.help"], ] as [string[], string][] @@ -708,6 +751,26 @@ export default function PlexPlayer({ itemId, onClose }: Props) { )} + + {/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */} + {infoOpen && detail && ( +
{ + e.stopPropagation(); + setInfoOpen(false); + }} + > +
e.stopPropagation()} + > + + setInfoOpen(false)} /> + +
+
+ )} ); } diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 9d14f34..79502db 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 a41342b..a77e2c6 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,20 @@ "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", + "match": { "any": "Beliebig", "all": "Alle" }, + "dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" } }, "count": "{{count}} Titel", "searchCount": "{{count}} Treffer", @@ -53,6 +70,7 @@ "errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen.", "stop": "Stopp", "help": "Tastenkürzel", + "infoBtn": "Medieninfo", "keys": { "playPause": "Wiedergabe / Pause", "seek": "10 Sekunden spulen", @@ -60,10 +78,26 @@ "volume": "Lautstärke lauter / leiser", "mute": "Stumm", "fullscreen": "Vollbild", + "info": "Medieninfo", "back": "Stopp & zurück zum Feed", "help": "Diese Hilfe anzeigen" } }, + "info": { + "title": "Medieninfo", + "customize": "Ansicht anpassen", + "prefArtBg": "Dezenter Hintergrund", + "prefCast": "Besetzung", + "openImdb": "Bei IMDb öffnen", + "director": "Regie", + "cast": "Besetzung & Crew", + "markWatched": "Als gesehen markieren", + "markUnwatched": "Gesehen zurücknehmen", + "clearResume": "Fortsetzungsposition löschen", + "filterYear": "Titel aus {{year}}", + "filterRating": "Titel mit {{n}}+", + "filterActor": "Titel mit {{name}}" + }, "playable": { "direct": "Spielt direkt im Browser", "remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 7c85b68..79bd804 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,20 @@ "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", + "match": { "any": "Any", "all": "All" }, + "dir": { "desc": "↓ Descending", "asc": "↑ Ascending" } }, "count": "{{count}} titles", "searchCount": "{{count}} matches", @@ -53,6 +70,7 @@ "errGeneric": "Playback couldn't start. Please try again.", "stop": "Stop", "help": "Keyboard shortcuts", + "infoBtn": "Media info", "keys": { "playPause": "Play / pause", "seek": "Seek 10 seconds", @@ -60,10 +78,26 @@ "volume": "Volume up / down", "mute": "Mute", "fullscreen": "Fullscreen", + "info": "Media info", "back": "Stop & back to feed", "help": "Show this help" } }, + "info": { + "title": "Media info", + "customize": "Customize view", + "prefArtBg": "Faint backdrop", + "prefCast": "Cast & crew", + "openImdb": "Open on IMDb", + "director": "Director", + "cast": "Cast & crew", + "markWatched": "Mark watched", + "markUnwatched": "Mark unwatched", + "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", "remux": "Needs a light remux (no video re-encode)", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 8162267..5131f55 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,20 @@ "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", + "match": { "any": "Bármelyik", "all": "Mind" }, + "dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" } }, "count": "{{count}} cím", "searchCount": "{{count}} találat", @@ -53,6 +70,7 @@ "errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.", "stop": "Leállítás", "help": "Billentyűparancsok", + "infoBtn": "Média infó", "keys": { "playPause": "Lejátszás / szünet", "seek": "Tekerés 10 másodperc", @@ -60,10 +78,26 @@ "volume": "Hangerő fel / le", "mute": "Némítás", "fullscreen": "Teljes képernyő", + "info": "Média infó", "back": "Leállítás és vissza a feedre", "help": "Súgó megjelenítése" } }, + "info": { + "title": "Média infó", + "customize": "Nézet testreszabása", + "prefArtBg": "Halvány háttér", + "prefCast": "Szereplők", + "openImdb": "Megnyitás az IMDb-n", + "director": "Rendező", + "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", + "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", "remux": "Könnyű remux kell (nincs videó-újrakódolás)", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7a8df7d..016d93f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -695,7 +695,15 @@ export interface PlexItemDetail { playable: string; // direct | remux | transcode thumb: string; art: string; - cast: string[]; + cast: PlexCastMember[]; + imdb_rating?: number | null; + imdb_id?: string | null; + imdb_url?: string | null; + content_rating?: string | null; + genres: string[]; + directors: string[]; + studio?: string | null; + tagline?: string | null; markers: PlexMarker[]; audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[]; subtitle_streams: { ord: number; label: string; language?: string | null }[]; @@ -707,6 +715,56 @@ export interface PlexItemDetail { prev_id?: string | null; next_id?: string | null; } +export interface PlexCastMember { + name: string; + 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[]; + genreMode?: "any" | "all"; // how multiple genres combine (default any) + 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" + directors: string[]; // AND — titles featuring ALL selected + actors: string[]; // AND — titles featuring ALL selected + studios: string[]; // OR — from any selected studio + sortDir?: "asc" | "desc"; // sort direction (default desc) +} +export const EMPTY_PLEX_FILTERS: PlexFilters = { + genres: [], + contentRatings: [], + directors: [], + actors: [], + studios: [], +}; +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.directors.length + + f.actors.length + + f.studios.length + ); +} +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; @@ -1036,6 +1094,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); @@ -1043,8 +1102,26 @@ 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.genreMode === "all") u.set("genre_mode", "all"); + 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.directors?.length) u.set("directors", f.directors.join(",")); + if (f.actors?.length) u.set("actors", f.actors.join(",")); + if (f.studios?.length) u.set("studios", f.studios.join(",")); + if (f.sortDir === "asc") u.set("sort_dir", "asc"); + } 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 a005b2c..943c964 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,31 @@ 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) with an ascending/descending toggle.", + "Plex filters: pick several genres and choose Any (match one) or All (match every one); stack multiple actors, directors and studios at once (from the info page).", + "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+). Browser Back returns to the info page.", + "Plex: posters and cast photos are cached on disk after first view, so scrolling the library is much smoother on repeat visits.", + ], + fixes: [ + "Plex player: leaving a video with Back now fully stops it — no more audio playing in the background until a refresh.", + ], + }, + { + version: "0.25.0", + date: "2026-07-05", + summary: "Rich Plex media info — an info page from any title, an info overlay while watching, IMDb scores and cast photos.", + features: [ + "Plex: every movie/episode now has a media info page (the “i” button on a card) with the poster, IMDb score + a link to IMDb, content rating, genres, director, a full cast list with photos, and the synopsis — plus Play/Resume and watch controls.", + "Plex player: press “I” (or the info button) for the same rich info as an overlay, without stopping playback.", + "Plex info: two view preferences you can toggle and that stick — a faint art backdrop and the cast & crew row.", + "Plex: you can now mark a title unwatched or clear its resume position from the info page.", + ], + }, { version: "0.24.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",