Merge feature/plex-series-view into dev

Plex series-view + unified-library epic + this session's follow-through:
- 3-level series view, unified movies+shows cross-library browser, adaptive facets
- code-review findings F1-F10 (F7 deferred): stale query keys, hidden-movie facet
  exclusion, best-effort enrichment, facet sort-key, optimistic episode toggle,
  bulk Plex-push batching, per-show enrichment cache+parallelize, dead-code + DRY
- Collections restored as a cross-library union picker
- genre facet offers only co-occurring genres in AND mode (+ empty-AND trap guard)

Local dev merge only — NOT published/shipped (prod stays v0.36.4).
This commit is contained in:
npeter83 2026-07-11 03:42:50 +02:00
commit a9edc48cb4
18 changed files with 2041 additions and 674 deletions

View file

@ -0,0 +1,91 @@
"""Plex show-level filterable/orderable metadata
Revision ID: 0052_plex_show_meta
Revises: 0051_plex_link
Create Date: 2026-07-10
Gives TV shows the same filterable metadata movies already have (0045/0046), mirrored cheaply from
the Plex show section listing (no per-item calls): rating (audienceRating ~ IMDb), content rating,
studio/network, first-air date, and GIN-indexed genre / director / cast arrays + a `people_text`
blob folded into the generated `search_vector` (weight B). This lets the TV grid be filtered and
sorted like the movie grid. A generated column's expression can't be altered in place, so the
search_vector is dropped + recreated. All new columns are populated on the next Plex sync.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0052_plex_show_meta"
down_revision: Union[str, None] = "0051_plex_link"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_CFG = "public.unaccent_simple"
_NEW_VECTOR = (
f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || "
f"setweight(to_tsvector('{_CFG}', coalesce(people_text, '')), 'B') || "
f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')"
)
_OLD_VECTOR = (
f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || "
f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')"
)
def _rebuild_vector(expr: str) -> None:
op.execute("DROP INDEX IF EXISTS ix_plex_shows_search_vector")
op.execute("ALTER TABLE plex_shows DROP COLUMN search_vector")
op.execute(
f"ALTER TABLE plex_shows ADD COLUMN search_vector tsvector "
f"GENERATED ALWAYS AS ({expr}) STORED"
)
op.execute("CREATE INDEX ix_plex_shows_search_vector ON plex_shows USING gin (search_vector)")
def upgrade() -> None:
op.add_column("plex_shows", sa.Column("rating", sa.Float(), nullable=True))
op.add_column("plex_shows", sa.Column("content_rating", sa.String(length=16), nullable=True))
op.add_column("plex_shows", sa.Column("studio", sa.String(length=255), nullable=True))
op.add_column("plex_shows", sa.Column("originally_available_at", sa.Date(), nullable=True))
op.add_column("plex_shows", sa.Column("genres", postgresql.JSONB(), nullable=True))
op.add_column("plex_shows", sa.Column("directors", postgresql.JSONB(), nullable=True))
op.add_column("plex_shows", sa.Column("cast_names", postgresql.JSONB(), nullable=True))
op.add_column("plex_shows", sa.Column("people_text", sa.Text(), nullable=True))
op.create_index("ix_plex_shows_rating", "plex_shows", ["rating"])
op.create_index("ix_plex_shows_content_rating", "plex_shows", ["content_rating"])
op.create_index("ix_plex_shows_studio", "plex_shows", ["studio"])
op.create_index("ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"])
op.create_index("ix_plex_shows_genres_gin", "plex_shows", ["genres"], postgresql_using="gin")
op.create_index("ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin")
op.create_index("ix_plex_shows_cast_gin", "plex_shows", ["cast_names"], postgresql_using="gin")
# NB: ix_plex_shows_collections_gin already exists (created with collections in 0047).
_rebuild_vector(_NEW_VECTOR)
def downgrade() -> None:
_rebuild_vector(_OLD_VECTOR)
for ix in (
"ix_plex_shows_cast_gin",
"ix_plex_shows_directors_gin",
"ix_plex_shows_genres_gin",
"ix_plex_shows_originally_available_at",
"ix_plex_shows_studio",
"ix_plex_shows_content_rating",
"ix_plex_shows_rating",
):
op.drop_index(ix, table_name="plex_shows")
for col in (
"people_text",
"cast_names",
"directors",
"genres",
"originally_available_at",
"studio",
"content_rating",
"rating",
):
op.drop_column("plex_shows", col)

View file

@ -956,9 +956,17 @@ class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
class PlexShow(Base, TimestampMixin, UpdatedAtMixin): class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page.""" """A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus
the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV
grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing."""
__tablename__ = "plex_shows" __tablename__ = "plex_shows"
__table_args__ = (
Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"),
Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"),
Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"),
# ix_plex_shows_collections_gin already created with collections (migration 0047).
)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True) rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
@ -973,10 +981,20 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
child_count: Mapped[int | None] = mapped_column(Integer) # seasons child_count: Mapped[int | None] = mapped_column(Integer) # seasons
collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date
genres: Mapped[list | None] = mapped_column(JSONB)
directors: Mapped[list | None] = mapped_column(JSONB)
cast_names: Mapped[list | None] = mapped_column(JSONB)
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
search_vector: Mapped[object | None] = mapped_column( search_vector: Mapped[object | None] = mapped_column(
TSVECTOR, TSVECTOR,
Computed( Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || " "setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')", "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
persisted=True, persisted=True,
), ),

View file

@ -96,6 +96,18 @@ class PlexClient:
mc = self._get(f"/library/metadata/{rating_key}/children") mc = self._get(f"/library/metadata/{rating_key}/children")
return mc.get("Metadata", []) or [] return mc.get("Metadata", []) or []
def related(self, rating_key: str) -> list[dict]:
"""Related / similar items for a movie or show, flattened out of Plex's Hub grouping.
Best-effort (needs the library's related data) — returns [] if Plex has none."""
try:
mc = self._get(f"/library/metadata/{rating_key}/related")
except PlexError:
return []
out: list[dict] = []
for hub in mc.get("Hub", []) or []:
out.extend(hub.get("Metadata", []) or [])
return out
def collections(self, section_key: str) -> list[dict]: def collections(self, section_key: str) -> list[dict]:
"""All collections in a library section (title/summary/thumb/childCount/smart).""" """All collections in a library section (title/summary/thumb/childCount/smart)."""
mc = self._get(f"/library/sections/{section_key}/collections") mc = self._get(f"/library/sections/{section_key}/collections")

View file

@ -230,6 +230,16 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
sh.art_key = meta.get("art") sh.art_key = meta.get("art")
sh.child_count = meta.get("childCount") sh.child_count = meta.get("childCount")
sh.added_at = _epoch(meta.get("addedAt")) sh.added_at = _epoch(meta.get("addedAt"))
# Filterable / orderable metadata — same cheap section-listing tags as movies.
sh.rating = _rating(meta)
sh.content_rating = (meta.get("contentRating") or None)
sh.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
sh.originally_available_at = _date(meta.get("originallyAvailableAt"))
sh.genres = _tags(meta, "Genre")
sh.directors = _tags(meta, "Director")
sh.cast_names = _tags(meta, "Role", limit=20)
people = list(dict.fromkeys((sh.directors or []) + (sh.cast_names or [])))
sh.people_text = " ".join(people) or None
stats["shows"] += 1 stats["shows"] += 1
db.flush() db.flush()
show_id = {rk: sh.id for rk, sh in shows.items()} show_id = {rk: sh.id for rk, sh in shows.items()}

View file

@ -206,6 +206,51 @@ def push_state_to_plex(
db.commit() db.commit()
def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -> None:
"""Coalesced Siftlode→Plex push for a whole-show / whole-season mark (see `_bulk_state`). Same
contract as `push_state_to_plex` (own DB session, best-effort, never raises) but ONE background
task for the entire batch: it reuses a single DB session and a single keep-alive PlexClient across
all `changes` instead of scheduling N tasks that each rebuild a session + HTTP client. Plex has no
batch-scrobble endpoint, so the per-item HTTP calls remain (looped over the shared connection), but
the O(N) task/session/client setup is gone. `changes` is a list of (item_id, rating_key, action)
with action ``watched`` (scrobble) or ``unwatched`` (unscrobble)."""
if not changes:
return
with SessionLocal() as db:
if link_for_push(db, user_id) is None:
return
synced_ids: list[int] = []
try:
with PlexClient(db) as plex:
for item_id, rating_key, action in changes:
try:
if action == "watched":
plex.scrobble(rating_key)
elif action == "unwatched":
plex.unscrobble(rating_key)
else:
continue
except PlexError as e:
# One bad item mustn't abort the rest of the batch; leave its row dirty
# (synced_to_plex=False) for a later reconcile.
log.warning(
"Plex bulk push failed (user %s, item %s, %s): %s", user_id, item_id, action, e
)
continue
synced_ids.append(item_id)
except PlexError as e:
# Couldn't even open the client — nothing pushed; everything stays dirty for reconcile.
log.warning("Plex bulk push could not connect (user %s): %s", user_id, e)
return
# Flag every successfully-pushed row that still exists (an "unwatched" deletes the row, so
# only the "watched" changes have a row to flag).
if synced_ids:
db.query(PlexState).filter(
PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids)
).update({PlexState.synced_to_plex: True}, synchronize_session=False)
db.commit()
# --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------ # --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------
# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just # Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just

View file

@ -11,6 +11,9 @@ import os
import re import re
import subprocess import subprocess
import tempfile import tempfile
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
@ -18,7 +21,7 @@ from urllib.parse import quote
import httpx import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlalchemy import and_, func, or_, text from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app import sysconfig from app import sysconfig
@ -228,7 +231,7 @@ def _movie_card(it: PlexItem, st: PlexState | None) -> dict:
} }
def _show_card(sh: PlexShow) -> dict: def _show_card(sh: PlexShow, status: str = "new") -> dict:
return { return {
"id": sh.rating_key, "id": sh.rating_key,
"type": "show", "type": "show",
@ -236,9 +239,80 @@ def _show_card(sh: PlexShow) -> dict:
"year": sh.year, "year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}", "thumb": f"/api/plex/image/{sh.rating_key}",
"season_count": sh.child_count, "season_count": sh.child_count,
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
"status": status,
} }
def _show_status(total: int, watched: int, inprog: int) -> str:
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
if total and watched >= total:
return "watched"
if (watched or 0) > 0 or (inprog or 0) > 0:
return "in_progress"
return "new"
def _rollup(cards: list[dict]) -> dict:
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
the episode count. Drives the show/season Resume + Play + mark-all buttons."""
total = len(cards)
watched = sum(1 for c in cards if c.get("status") == "watched")
inprog = [c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"]
resume = None
if inprog:
resume = inprog[-1] # continue the latest-started episode
else:
resume = next((c for c in cards if c.get("status") != "watched"), None)
return {
"status": _show_status(total, watched, len(inprog)),
"resume": resume,
"first": cards[0] if cards else None,
"episode_count": total,
}
def _show_agg_subq(db: Session, user_id: int):
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
grid by aggregate watch-state and (b) compute each card's badge."""
ps = aliased(PlexState)
return (
db.query(
PlexItem.show_id.label("show_id"),
func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"),
func.count(
case(
(
and_(ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched")),
1,
)
)
).label("inprog"),
)
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
.filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
.group_by(PlexItem.show_id)
)
def _show_state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
if not show_ids:
return {}
rows = _show_agg_subq(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all()
return {r.show_id: _show_status(r.total, r.watched, r.inprog) for r in rows}
def _lib_key(db: Session, library_id: int | None) -> str | None:
"""The Plex section key for a library id — the info/show page passes it to the admin collection
editor (in the unified cross-library view there's no single library prop otherwise)."""
if library_id is None:
return None
lib = db.get(PlexLibrary, library_id)
return lib.plex_key if lib else None
def _episode_card(e: PlexItem, st: PlexState | None) -> dict: def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
return { return {
"id": e.rating_key, "id": e.rating_key,
@ -280,11 +354,50 @@ def _added_cutoff(within: str | None) -> datetime | None:
return datetime.now(timezone.utc) - timedelta(days=days) if days else None return datetime.now(timezone.utc) - timedelta(days=days) if days else None
@router.get("/browse") def _meta_filter_conds(model, p: dict) -> list:
def browse( """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for
library: str, `p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a
list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets."""
conds: list = []
gsel = _csv(p.get("genres"))
if gsel:
gc = [model.genres.contains([g]) for g in gsel]
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
crs = _csv(p.get("content_ratings"))
if crs:
conds.append(model.content_rating.in_(crs))
if p.get("year_min") is not None:
conds.append(model.year >= p["year_min"])
if p.get("year_max") is not None:
conds.append(model.year <= p["year_max"])
if p.get("rating_min") is not None:
conds.append(model.rating >= p["rating_min"])
cutoff = _added_cutoff(p.get("added_within"))
if cutoff is not None:
conds.append(model.added_at >= cutoff)
for d in _csv(p.get("directors")): # AND
conds.append(model.directors.contains([d]))
for a in _csv(p.get("actors")): # AND
conds.append(model.cast_names.contains([a]))
stds = _csv(p.get("studios"))
if stds: # OR
conds.append(model.studio.in_(stds))
if p.get("collection"):
conds.append(model.collection_keys.contains([p["collection"]]))
return conds
def _apply_meta_filters(q, model, p: dict):
conds = _meta_filter_conds(model, p)
return q.filter(*conds) if conds else q
@router.get("/library")
def unified_library(
scope: str = "both",
q: str | None = None, q: str | None = None,
sort: str = "added", sort: str = "added",
sort_dir: str = "desc",
show: str = "all", show: str = "all",
offset: int = 0, offset: int = 0,
limit: int = Query(default=40, ge=1, le=100), limit: int = Query(default=40, ge=1, le=100),
@ -301,120 +414,195 @@ def browse(
actors: str | None = None, actors: str | None = None,
studios: str | None = None, studios: str | None = None,
collection: str | None = None, collection: str | None = None,
sort_dir: str = "desc",
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""List a library's movies (leaves) or shows, with optional FTS search + sorting + a per-user """Unified cross-library browse: movies + shows in ONE mixed, paginated feed, scoped to
watch-state filter (`show`: all|unwatched|in_progress|watched movie libraries only). Movie movie|show|both, with the shared filters/search/watch-state (a show's state is the aggregate of
libraries return playable movie cards; show libraries return show cards (drill in via /show).""" its episodes). On a search that also matches episodes (and shows are in scope), the matching
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() episodes come back in a separate `episodes` list (grouped result the 'Episodes' section)."""
if lib is None:
raise HTTPException(status_code=404, detail="Unknown Plex library")
offset = max(0, offset) offset = max(0, offset)
model = PlexItem if lib.kind == "movie" else PlexShow p = {
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
query = db.query(model) "year_min": year_min, "year_max": year_max, "rating_min": rating_min,
if lib.kind == "movie": "added_within": added_within, "directors": directors, "actors": actors,
query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie") "studios": studios, "collection": collection,
# Per-user watch-state filter (movies only; a show isn't a single watch unit). }
st = aliased(PlexState) libs = db.query(PlexLibrary).filter_by(enabled=True).all()
query = query.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
if show == "watched": show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
query = query.filter(st.status == "watched") want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
elif show == "in_progress": want_shows = scope in ("show", "both") and bool(show_lib_ids)
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
elif show == "unwatched":
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)
# Collection filter applies to both movies and shows (both carry collection_keys).
if collection:
query = query.filter(model.collection_keys.contains([collection]))
ts = _to_tsquery_str(q) if q else None ts = _to_tsquery_str(q) if q else None
tsq = None tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None
if ts:
tsq = func.to_tsquery(_TS_CONFIG, ts)
query = query.filter(model.search_vector.op("@@")(tsq))
total = query.count() selects = []
if want_movies:
st = aliased(PlexState)
m_status = case(
(st.status == "watched", "watched"),
(and_(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")), "in_progress"),
else_="new",
)
mq = (
db.query(
PlexItem.rating_key.label("rk"),
literal("movie").label("kind"),
PlexItem.title.label("title"),
PlexItem.year.label("year"),
PlexItem.rating.label("rating"),
PlexItem.added_at.label("added_at"),
PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"),
PlexItem.playable.label("playable"),
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
(func.ts_rank(PlexItem.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"),
)
.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
.filter(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
)
if show == "watched":
mq = mq.filter(st.status == "watched")
elif show == "in_progress":
mq = mq.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
elif show == "unwatched":
mq = mq.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
else:
mq = mq.filter(or_(st.status.is_(None), st.status != "hidden"))
mq = _apply_meta_filters(mq, PlexItem, p)
if duration_min is not None:
mq = mq.filter(PlexItem.duration_s >= duration_min)
if duration_max is not None:
mq = mq.filter(PlexItem.duration_s <= duration_max)
if tsq is not None:
mq = mq.filter(PlexItem.search_vector.op("@@")(tsq))
selects.append(mq)
if want_shows:
agg = _show_agg_subq(db, user.id).subquery()
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
s_status = case(
(and_(tot > 0, wat >= tot), "watched"),
(or_(wat > 0, inp > 0), "in_progress"),
else_="new",
)
sq = (
db.query(
PlexShow.rating_key.label("rk"),
literal("show").label("kind"),
PlexShow.title.label("title"),
PlexShow.year.label("year"),
PlexShow.rating.label("rating"),
PlexShow.added_at.label("added_at"),
PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"),
cast(null(), String).label("playable"),
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
(func.ts_rank(PlexShow.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"),
)
.outerjoin(agg, agg.c.show_id == PlexShow.id)
.filter(PlexShow.library_id.in_(show_lib_ids))
)
if show == "watched":
sq = sq.filter(tot > 0, wat >= tot)
elif show == "in_progress":
sq = sq.filter(or_(wat > 0, inp > 0), wat < tot)
elif show == "unwatched":
sq = sq.filter(wat == 0, inp == 0)
sq = _apply_meta_filters(sq, PlexShow, p)
if tsq is not None:
sq = sq.filter(PlexShow.search_vector.op("@@")(tsq))
selects.append(sq)
if not selects:
return {"scope": scope, "total": 0, "offset": offset, "limit": limit, "items": [], "episodes": []}
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
u = union.subquery()
total = db.query(func.count()).select_from(u).scalar() or 0
if tsq is not None: if tsq is not None:
order = [func.ts_rank(model.search_vector, tsq).desc()] order = [u.c.rank.desc()]
else: else:
if sort == "title": col = {
col = func.lower(model.title) "title": func.lower(u.c.title),
elif sort == "year" and model is PlexItem: "year": u.c.year,
col = PlexItem.year "rating": u.c.rating,
elif sort == "rating" and model is PlexItem: "duration": u.c.duration_s,
col = PlexItem.rating "release": u.c.rel,
elif sort == "duration" and model is PlexItem: }.get(sort, u.c.added_at)
col = PlexItem.duration_s order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
elif sort == "release" and model is PlexItem: order.append(u.c.rk.desc())
col = PlexItem.originally_available_at rows = db.query(u).order_by(*order).offset(offset).limit(limit).all()
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() items = []
for r in rows:
if r.kind == "movie":
items.append({
"id": r.rk, "type": "movie", "title": r.title, "year": r.year,
"duration_seconds": r.duration_s, "thumb": f"/api/plex/image/{r.rk}",
"playable": r.playable, "status": r.status, "position_seconds": r.position_seconds,
})
else:
items.append({
"id": r.rk, "type": "show", "title": r.title, "year": r.year,
"thumb": f"/api/plex/image/{r.rk}", "season_count": r.season_count, "status": r.status,
})
if lib.kind == "movie": episodes = []
by_item = {r.id: r for r in rows} if tsq is not None and want_shows:
states = { ep_st = aliased(PlexState)
s.item_id: s eps = (
for s in db.query(PlexState).filter( db.query(PlexItem, ep_st, PlexShow.title.label("show_title"))
PlexState.user_id == user.id, PlexState.item_id.in_(list(by_item.keys()) or [0]) .outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id))
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
.filter(
PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids),
PlexItem.search_vector.op("@@")(tsq),
) )
} .order_by(func.ts_rank(PlexItem.search_vector, tsq).desc())
items = [_movie_card(r, states.get(r.id)) for r in rows] .limit(24)
else: .all()
items = [_show_card(r) for r in rows] )
for e, s, show_title in eps:
card = _episode_card(e, s)
card["show_title"] = show_title
episodes.append(card)
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items} return {"scope": scope, "total": total, "offset": offset, "limit": limit, "items": items, "episodes": episodes}
@router.get("/facets") @router.get("/facets")
def facets( def facets(
library: str, scope: str = "both",
show: str = "all",
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,
collection: str | None = None,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""Available filter values for a movie library — genres + content ratings (with counts) and the """Available filter values for the unified library (scope movie|show|both), ADAPTED to the active
year/rating/duration bounds so the sidebar only offers what the library actually contains.""" filters (faceted search): each group's values/bounds are computed with all the OTHER active
filters applied but NOT its own so selecting a filter narrows what the other groups offer, while
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
movie-only. (Search text is not factored in facets track the sidebar filters.)"""
empty = { empty = {
"genres": [], "genres": [],
"content_ratings": [], "content_ratings": [],
@ -424,33 +612,124 @@ def facets(
"duration_min": None, "duration_min": None,
"duration_max": None, "duration_max": None,
} }
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() libs = db.query(PlexLibrary).filter_by(enabled=True).all()
if lib is None or lib.kind != "movie": movie_ids = [lb.id for lb in libs if lb.kind == "movie"] if scope in ("movie", "both") else []
show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else []
if not movie_ids and not show_ids:
return empty return empty
base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'" p = {
genres = db.execute( "genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
text( "year_min": year_min, "year_max": year_max, "rating_min": rating_min,
f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x " "duration_min": duration_min, "duration_max": duration_max, "added_within": added_within,
"GROUP BY g ORDER BY c DESC, g" "directors": directors, "actors": actors, "studios": studios, "collection": collection,
), }
{"lib": lib.id},
).all() uid = user.id
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"), def wsq(query, model, ids, pp, kind_movie, with_dur):
{"lib": lib.id}, """Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
).all() facet aggregation query, so the facets reflect exactly the visible result set."""
b = db.execute( query = query.filter(model.library_id.in_(ids), *_meta_filter_conds(model, pp))
text(f"SELECT min(year), max(year), max(rating), min(duration_s), max(duration_s) {base}"), if kind_movie:
{"lib": lib.id}, query = query.filter(model.kind == "movie")
if with_dur and pp.get("duration_min") is not None:
query = query.filter(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None:
query = query.filter(model.duration_s <= pp["duration_max"])
# Always join watch-state and mirror the grid's status branches (unified_library) exactly —
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
st = aliased(PlexState)
query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid))
if show == "watched":
query = query.filter(st.status == "watched")
elif show == "in_progress":
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
elif show == "unwatched":
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
else:
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
else:
if show in ("watched", "in_progress", "unwatched"):
agg = _show_agg_subq(db, uid).subquery()
query = query.outerjoin(agg, agg.c.show_id == model.id)
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
if show == "watched":
query = query.filter(tot > 0, wat >= tot)
elif show == "in_progress":
query = query.filter(or_(wat > 0, inp > 0), wat < tot)
else:
query = query.filter(wat == 0, inp == 0)
return query
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
def per_table(model, ids, kind_movie: bool) -> None:
if not ids:
return
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
# every matching title carries them, so they stay togglable.)
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
expanded = wsq(
db.query(func.jsonb_array_elements_text(model.genres).label("g")),
model, ids, genre_pp, kind_movie, True,
).subquery()
for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all():
genre_counts[g] = genre_counts.get(g, 0) + c
# Content ratings — exclude its own.
crq = wsq(
db.query(model.content_rating, func.count()),
model, ids, {**p, "content_ratings": None}, kind_movie, True,
).filter(model.content_rating.isnot(None)).group_by(model.content_rating)
for cr, c in crq.all():
cr_counts[cr] = cr_counts.get(cr, 0) + c
# Year bounds — exclude the year filter.
by = wsq(
db.query(func.min(model.year), func.max(model.year)),
model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True,
).first() ).first()
if by[0] is not None:
years.append(by[0])
if by[1] is not None:
years.append(by[1])
# Rating max — exclude the rating filter.
br = wsq(db.query(func.max(model.rating)), model, ids, {**p, "rating_min": None}, kind_movie, True).scalar()
if br is not None:
ratings.append(float(br))
# Duration bounds (movies) — exclude the duration filter.
if kind_movie:
bd = wsq(
db.query(func.min(model.duration_s), func.max(model.duration_s)), model, ids, p, kind_movie, False
).first()
if bd[0] is not None:
durs.append(bd[0])
if bd[1] is not None:
durs.append(bd[1])
per_table(PlexItem, movie_ids, True)
per_table(PlexShow, show_ids, False)
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
for g in _csv(genres):
genre_counts.setdefault(g, 0)
return { return {
"genres": [{"value": g, "count": c} for g, c in genres], "genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])],
"content_ratings": [{"value": cr, "count": c} for cr, c in crs], "content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])],
"year_min": b[0], "year_min": min(years) if years else None,
"year_max": b[1], "year_max": max(years) if years else None,
"rating_max": float(b[2]) if b[2] is not None else None, "rating_max": max(ratings) if ratings else None,
"duration_min": b[3], "duration_min": min(durs) if durs else None,
"duration_max": b[4], "duration_max": max(durs) if durs else None,
} }
@ -458,69 +737,6 @@ def _like_escape(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@router.get("/people")
def people(
q: str,
library: str,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Cast/crew whose name matches the search term — drives the virtual person cards shown above the
grid. Returns up to a few matches (most films first) with a count + a photo."""
term = (q or "").strip()
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
if lib is None or lib.kind != "movie" or len(term) < 2:
return {"people": []}
esc = _like_escape(term)
pfx, word = f"{esc}%", f"% {esc}%" # name starts with the term, or any of its words does
rows = db.execute(
text(
"SELECT name, kind, count(*) c FROM ("
" SELECT jsonb_array_elements_text(cast_names) AS name, 'actor' AS kind FROM plex_items"
" WHERE library_id = :lib AND kind = 'movie'"
" UNION ALL"
" SELECT jsonb_array_elements_text(directors) AS name, 'director' AS kind FROM plex_items"
" WHERE library_id = :lib AND kind = 'movie'"
") x WHERE name ILIKE :pfx OR name ILIKE :word "
"GROUP BY name, kind ORDER BY c DESC, name LIMIT 5"
),
{"lib": lib.id, "pfx": pfx, "word": word},
).all()
return {
"people": [
{"name": name, "kind": kind, "count": c, "photo": _person_photo(db, lib.id, name, kind)}
for name, kind, c in rows
]
}
def _person_photo(db: Session, lib_id: int, name: str, kind: str) -> str | None:
"""A person's headshot from one representative film's live metadata (image bytes are disk-cached)."""
col = PlexItem.directors if kind == "director" else PlexItem.cast_names
rk = (
db.query(PlexItem.rating_key)
.filter(PlexItem.library_id == lib_id, col.contains([name]))
.limit(1)
.scalar()
)
if not rk:
return None
try:
with PlexClient(db) as plex:
meta = plex.metadata(rk) or {}
except (PlexError, PlexNotConfigured):
return None
for r in meta.get("Director" if kind == "director" else "Role") or []:
if r.get("tag") == name:
thumb = str(r.get("thumb") or "")
return (
f"/api/plex/person-image?u={quote(thumb, safe='')}"
if thumb.startswith(_PERSON_IMG_HOST)
else None
)
return None
def _collection_editable(c: PlexCollection) -> bool: def _collection_editable(c: PlexCollection) -> bool:
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never """Effective editability: the admin marked it editable AND it's a plain manual collection — never
smart, never an external auto-list (IMDb/TMDb/), which are managed outside Plex.""" smart, never an external auto-list (IMDb/TMDb/), which are managed outside Plex."""
@ -543,16 +759,25 @@ def _collection_card(c: PlexCollection) -> dict:
@router.get("/collections") @router.get("/collections")
def collections( def collections(
library: str, library: str | None = None,
q: str | None = None, q: str | None = None,
user: User = Depends(current_user), user: User = Depends(current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""The library's mirrored collections as cards (most-populated first). Optional name filter `q`.""" """Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a
`library` plex_key that one library's collections (used by the collection editor). WITHOUT one →
the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single
library selected)."""
if library is not None:
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
if lib is None: if lib is None:
return {"collections": []} return {"collections": []}
query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id) query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id)
else:
enabled_ids = [lb.id for lb in db.query(PlexLibrary.id).filter_by(enabled=True)]
if not enabled_ids:
return {"collections": []}
query = db.query(PlexCollection).filter(PlexCollection.library_id.in_(enabled_ids))
term = (q or "").strip() term = (q or "").strip()
if term: if term:
query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%")) query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%"))
@ -921,6 +1146,42 @@ def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user)
return {"ok": True} return {"ok": True}
# Live-Plex enrichment for a show (cast/IMDb + related) is user-independent raw payload, so it's cached
# per rating_key for a short TTL and shared across users; per-user shaping happens in the caller. Bounds
# staleness to the TTL while turning repeat opens of the same show into zero network calls.
_SHOW_ENRICH_TTL_S = 300
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
_show_enrich_lock = threading.Lock()
def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]:
"""(meta, related_raw) for a show's live Plex enrichment — served from the per-show TTL cache when
warm, otherwise fetched with the two Plex calls (metadata + related) run IN PARALLEL. Returns
({}, []) when Plex is unavailable/unconfigured (not cached, so it retries on the next open)."""
now = time.monotonic()
with _show_enrich_lock:
hit = _show_enrich_cache.get(rating_key)
if hit is not None and hit[0] > now:
return hit[1], hit[2]
try:
with PlexClient(db) as plex:
with ThreadPoolExecutor(max_workers=2) as ex:
meta_f = ex.submit(plex.metadata, rating_key)
related_f = ex.submit(plex.related, rating_key) # swallows its own errors → []
meta = meta_f.result() or {}
related_raw = related_f.result()
except (PlexError, PlexNotConfigured):
return {}, []
# Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a
# genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL
# even after Plex recovers. Skip the cache in that case (retry next open, as before); shows that DO
# have related items — the payload worth caching — still get cached.
if related_raw:
with _show_enrich_lock:
_show_enrich_cache[rating_key] = (now + _SHOW_ENRICH_TTL_S, meta, related_raw)
return meta, related_raw
@router.get("/show/{rating_key}") @router.get("/show/{rating_key}")
def show_detail( def show_detail(
rating_key: str, rating_key: str,
@ -947,8 +1208,27 @@ def show_detail(
) )
} }
by_season: dict[int | None, list] = {} by_season: dict[int | None, list] = {}
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
for e in eps: for e in eps:
by_season.setdefault(e.season_id, []).append(_episode_card(e, states.get(e.id))) card = _episode_card(e, states.get(e.id))
by_season.setdefault(e.season_id, []).append(card)
all_cards.append(card)
show_roll = _rollup(all_cards)
# Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). The two live
# Plex calls are cached per-show + fetched in parallel by _show_enrichment; the per-user shaping
# (_related_show_cards adds this user's watch-state) stays out of the shared cache.
rich: dict = {}
related: list[dict] = []
try:
meta, related_raw = _show_enrichment(db, sh.rating_key)
rich = _rich_meta(meta)
related = _related_show_cards(db, user.id, related_raw, exclude=sh.id)
except Exception:
# Best-effort enrichment only — the local episode list is already assembled, so any live-Plex
# failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500.
pass
return { return {
"show": { "show": {
"id": sh.rating_key, "id": sh.rating_key,
@ -957,18 +1237,70 @@ def show_detail(
"year": sh.year, "year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}", "thumb": f"/api/plex/image/{sh.rating_key}",
"art": f"/api/plex/image/{sh.rating_key}?variant=art", "art": f"/api/plex/image/{sh.rating_key}?variant=art",
"library": _lib_key(db, sh.library_id),
"content_rating": sh.content_rating,
"rating": sh.rating,
"genres": sh.genres or [],
"studio": sh.studio,
"season_count": sh.child_count,
"imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"),
"imdb_url": rich.get("imdb_url"),
"cast": rich.get("cast", []),
# Show-level rollup for the hero action buttons.
"status": show_roll["status"],
"resume": show_roll["resume"],
"first": show_roll["first"],
"episode_count": show_roll["episode_count"],
"collection_keys": sh.collection_keys or [],
}, },
"seasons": [ "seasons": [
{ _season_block(se, by_season.get(se.id, []))
for se in seasons
],
"related": related,
}
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
season subpage, read from the same cached show-detail payload)."""
roll = _rollup(cards)
return {
"id": se.rating_key, "id": se.rating_key,
"season_number": se.season_number, "season_number": se.season_number,
"title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"), "title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"),
"thumb": f"/api/plex/image/{se.rating_key}", "thumb": f"/api/plex/image/{se.rating_key}",
"episodes": by_season.get(se.id, []), "episode_count": roll["episode_count"],
"status": roll["status"],
"resume": roll["resume"],
"first": roll["first"],
"episodes": cards,
} }
for se in seasons
],
def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: int) -> list[dict]:
"""Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable),
excluding the current show. Order preserved; capped."""
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
if not rks:
return []
shows = {
s.rating_key: s
for s in db.query(PlexShow).filter(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
} }
statuses = _show_state_map(db, user_id, [s.id for s in shows.values()])
out: list[dict] = []
seen: set[str] = set()
for rk in rks:
sh = shows.get(rk)
if sh is None or rk in seen:
continue
seen.add(rk)
out.append(_show_card(sh, statuses.get(sh.id, "new")))
if len(out) >= 20:
break
return out
def _image_key(db: Session, rating_key: str, variant: str) -> str | None: def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
@ -1448,8 +1780,8 @@ def item_detail(
"text": codec not in _BITMAP_SUBS, "text": codec not in _BITMAP_SUBS,
} }
) )
except (PlexError, PlexNotConfigured): except Exception:
pass # metadata extras are best-effort; playback works without them pass # metadata extras are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
prev_id, next_id = _episode_nav(db, it) prev_id, next_id = _episode_nav(db, it)
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
@ -1463,6 +1795,7 @@ def item_detail(
"playable": it.playable, "playable": it.playable,
"thumb": f"/api/plex/image/{it.rating_key}", "thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art", "art": f"/api/plex/image/{it.rating_key}?variant=art",
"library": _lib_key(db, it.library_id),
"cast": rich.get("cast", []), "cast": rich.get("cast", []),
"imdb_rating": rich.get("imdb_rating"), "imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"), "imdb_id": rich.get("imdb_id"),
@ -1609,6 +1942,82 @@ def item_state(
return {"status": status} return {"status": status}
def _bulk_state(
background: BackgroundTasks, db: Session, user_id: int, eps: list[PlexItem], watched: bool
) -> int:
"""Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a
linked Plex account in the background (best-effort). Returns how many rows actually changed."""
now = datetime.now(timezone.utc)
existing = {
s.item_id: s
for s in db.query(PlexState).filter(
PlexState.user_id == user_id, PlexState.item_id.in_([e.id for e in eps] or [0])
)
}
pushable = plex_watch.link_for_push(db, user_id) is not None
to_push: list[tuple[int, str, str]] = []
for e in eps:
st = existing.get(e.id)
prev = st.status if st is not None else "new"
if watched:
if st is None:
st = PlexState(user_id=user_id, item_id=e.id)
db.add(st)
st.status = "watched"
st.watched_at = now
st.position_seconds = 0
st.synced_to_plex = False
if prev != "watched":
to_push.append((e.id, e.rating_key, "watched"))
else: # → new: drop any state (but keep the user's private "hidden" marks)
if st is not None and prev != "hidden":
db.delete(st)
if prev != "new":
to_push.append((e.id, e.rating_key, "unwatched"))
db.commit()
if pushable and to_push:
# One coalesced background task for the whole batch (reuses a single session + Plex client),
# not one task per episode — a 200-ep show mark is 1 task, not 200.
background.add_task(plex_watch.push_bulk_state_to_plex, user_id, to_push)
return len(to_push)
@router.post("/show/{rating_key}/state")
def show_state(
rating_key: str,
payload: dict,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex."""
sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first()
if sh is None:
raise HTTPException(status_code=404, detail="Unknown Plex show")
watched = bool(payload.get("watched"))
eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched}
@router.post("/season/{rating_key}/state")
def season_state(
rating_key: str,
payload: dict,
background: BackgroundTasks,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex."""
se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first()
if se is None:
raise HTTPException(status_code=404, detail="Unknown Plex season")
watched = bool(payload.get("watched"))
eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all()
changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched}
@router.post("/stream/{rating_key}/session") @router.post("/stream/{rating_key}/session")
def stream_session( def stream_session(
rating_key: str, rating_key: str,

View file

@ -246,9 +246,12 @@ export default function App() {
const channelsView: ChannelsView = const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed"; channelsViewRaw === "discovery" ? "discovery" : "subscribed";
// Plex module filters (its own left-sidebar filter section) — per-account persisted. // Plex module filters (its own left-sidebar filter section) — per-account persisted.
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, ""); // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all"); const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added"); const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title");
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it). // The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}"); const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
@ -265,7 +268,7 @@ export default function App() {
// greeted you with a stale query (colliding with a persisted collection filter → confusing // greeted you with a stale query (colliding with a persisted collection filter → confusing
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but // "0 matches") after a reload. Kept in App so it survives page switches within a session, but
// resets on reload. // resets on reload.
const [plexQ, setPlexQ] = useState(""); const [plexQ, setPlexQ] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search
// Bumped to tell the channel manager to drop a stale column filter when we send the user // 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). // there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0); const [channelsFilterReset, setChannelsFilterReset] = useState(0);
@ -740,8 +743,8 @@ export default function App() {
{page === "plex" && meQuery.data!.plex_enabled && ( {page === "plex" && meQuery.data!.plex_enabled && (
<Suspense fallback={null}> <Suspense fallback={null}>
<PlexSidebar <PlexSidebar
library={plexLib} scope={plexScope}
setLibrary={setPlexLib} setScope={setPlexScope}
show={plexShowFilter} show={plexShowFilter}
setShow={setPlexShowFilter} setShow={setPlexShowFilter}
sort={plexSort} sort={plexSort}
@ -829,7 +832,8 @@ export default function App() {
<PlexBrowse <PlexBrowse
q={plexQ} q={plexQ}
onClearSearch={() => setPlexQ("")} onClearSearch={() => setPlexQ("")}
library={plexLib} scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter} show={plexShowFilter}
sort={plexSort} sort={plexSort}
filters={plexFilters} filters={plexFilters}

File diff suppressed because it is too large Load diff

View file

@ -50,7 +50,7 @@ export default function PlexCollectionEditor({
const refresh = () => { const refresh = () => {
qc.invalidateQueries({ queryKey: ["plex-collections"] }); qc.invalidateQueries({ queryKey: ["plex-collections"] });
qc.invalidateQueries({ queryKey: ["plex-item", item.id] }); qc.invalidateQueries({ queryKey: ["plex-item", item.id] });
qc.invalidateQueries({ queryKey: ["plex-browse"] }); qc.invalidateQueries({ queryKey: ["plex-library"] });
onChanged(); onChanged();
}; };

View file

@ -1,19 +1,9 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useDismiss } from "../lib/useDismiss"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
import {
Check,
ExternalLink,
FolderPlus,
ListPlus,
Play,
RotateCcw,
SlidersHorizontal,
Star,
X,
} from "lucide-react";
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd"; import PlexPlaylistAdd from "./PlexPlaylistAdd";
@ -38,26 +28,6 @@ type Props = {
library?: string; library?: string;
}; };
// 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 { function fmtDur(s?: number | null): string {
if (!s) return ""; if (!s) return "";
const h = Math.floor(s / 3600); const h = Math.floor(s / 3600);
@ -78,11 +48,12 @@ export default function PlexInfo({
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
// Personal display prefs (default ON). Stored per-user; the backend merges any pref key. // Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs();
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean; plexHiddenStripSources?: string[] }; {}) as { plexHiddenStripSources?: string[] };
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled // Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here. // per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
const [hiddenSources, setHiddenSources] = useState<string[]>( const [hiddenSources, setHiddenSources] = useState<string[]>(
@ -98,52 +69,16 @@ export default function PlexInfo({
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin"; const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
const [editorOpen, setEditorOpen] = useState(false); const [editorOpen, setEditorOpen] = useState(false);
const [playlistOpen, setPlaylistOpen] = useState(false); const [playlistOpen, setPlaylistOpen] = useState(false);
const [customizing, setCustomizing] = useState(false);
const custBtnRef = useRef<HTMLButtonElement>(null);
const custMenuRef = useRef<HTMLDivElement>(null);
useDismiss(customizing, () => setCustomizing(false), [custMenuRef, custBtnRef]);
// Faint art backdrop, HTPC-style: paint the item's art as a FIXED background on the page's scroll // Faint art backdrop, HTPC-style (page variant only) — the shared hook paints the item's art as a
// container (<main>) so it fills the content area and stays put while the info page scrolls over it // fixed background on <main> and clears it on unmount / when disabled / in the overlay variant.
// (like the body's ambient pools). Scoped to <main> → never covers the nav/filter sidebars. A soft useArtBackdrop(detail.art, variant !== "overlay" && artBg);
// scrim keeps text readable; kept subtle. Page variant only, toggleable, restored on unmount/off.
useLayoutEffect(() => {
if (variant === "overlay") return;
const main = document.querySelector("main");
if (!main) return;
const s = main.style;
const clear = () => {
s.backgroundImage = "";
s.backgroundSize = "";
s.backgroundPosition = "";
s.backgroundRepeat = "";
s.backgroundAttachment = "";
};
if (artBg && detail.art) {
s.backgroundImage =
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${detail.art}")`;
s.backgroundSize = "cover";
s.backgroundPosition = "center 20%";
s.backgroundRepeat = "no-repeat";
s.backgroundAttachment = "fixed";
} else {
clear();
}
return clear;
}, [variant, artBg, detail.art]);
const savePref = (patch: Record<string, unknown>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched"; const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
const setState = async (status: "new" | "watched") => { const setState = async (status: "new" | "watched") => {
await api.plexSetState(detail.id, status).catch(() => {}); await api.plexSetState(detail.id, status).catch(() => {});
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] }); qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
qc.invalidateQueries({ queryKey: ["plex-browse"] }); qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: ["plex-show"] }); qc.invalidateQueries({ queryKey: ["plex-show"] });
onStateChange?.(); onStateChange?.();
}; };
@ -180,38 +115,14 @@ export default function PlexInfo({
</p> </p>
)} )}
</div> </div>
<div className="relative shrink-0"> <DetailCustomizeMenu
<button overlay={overlay}
ref={custBtnRef} artBg={artBg}
onClick={() => setCustomizing((v) => !v)} onToggleArtBg={toggleArtBg}
title={t("plex.info.customize")} showCast={showCast}
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`} onToggleCast={toggleCast}
> hasCast={detail.cast.length > 0}
<SlidersHorizontal className="w-4 h-4" /> extra={presentSources.map((src) => (
</button>
{customizing && (
<div
ref={custMenuRef}
className="glass-menu absolute right-0 top-full z-20 mt-1 w-52 rounded-xl p-1.5 text-sm"
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
>
<PrefToggle
label={t("plex.info.prefArtBg")}
on={artBg}
onClick={() => {
setArtBg((v) => !v);
savePref({ plexInfoArtBg: !artBg });
}}
/>
<PrefToggle
label={t("plex.info.prefCast")}
on={showCast}
onClick={() => {
setShowCast((v) => !v);
savePref({ plexInfoCast: !showCast });
}}
/>
{presentSources.map((src) => (
<PrefToggle <PrefToggle
key={src} key={src}
label={sourceLabel(src)} label={sourceLabel(src)}
@ -219,9 +130,7 @@ export default function PlexInfo({
onClick={() => toggleSource(src)} onClick={() => toggleSource(src)}
/> />
))} ))}
</div> />
)}
</div>
{overlay && onClose && ( {overlay && onClose && (
<button <button
onClick={onClose} onClick={onClose}
@ -534,21 +443,3 @@ export default function PlexInfo({
</div> </div>
); );
} }
function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
>
<span>{label}</span>
<span
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
/>
</span>
</button>
);
}

View file

@ -489,7 +489,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
old ? { ...old, position_seconds: pos } : old, old ? { ...old, position_seconds: pos } : old,
); );
} }
qc.invalidateQueries({ queryKey: ["plex-browse"] }); qc.invalidateQueries({ queryKey: ["plex-library"] });
qc.invalidateQueries({ queryKey: ["plex-show"] }); qc.invalidateQueries({ queryKey: ["plex-show"] });
}; };
}, [id, qc]); }, [id, qc]);

View file

@ -1,4 +1,4 @@
import { useEffect, useState, type ReactNode } from "react"; import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react"; import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
@ -12,8 +12,8 @@ import { useDebounced } from "../lib/useDebounced";
// backend so the sidebar only offers what the library actually contains. // backend so the sidebar only offers what the library actually contains.
type Props = { type Props = {
library: string; scope: string; // movie | show | both (unified cross-library scope)
setLibrary: (v: string) => void; setScope: (v: string) => void;
show: string; show: string;
setShow: (v: string) => void; setShow: (v: string) => void;
sort: string; sort: string;
@ -27,7 +27,8 @@ type Props = {
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const; const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
const SHOW_SORTS = ["added", "title"] as const; // Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime).
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
const RATING_STEPS = [5, 6, 7, 8, 9]; const RATING_STEPS = [5, 6, 7, 8, 9];
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
// Duration buckets → [minSeconds|null, maxSeconds|null]. // Duration buckets → [minSeconds|null, maxSeconds|null].
@ -38,8 +39,8 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
]; ];
export default function PlexSidebar({ export default function PlexSidebar({
library, scope,
setLibrary, setScope,
show, show,
setShow, setShow,
sort, sort,
@ -51,7 +52,6 @@ export default function PlexSidebar({
onToggleCollapse, onToggleCollapse,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState(""); const [newPlaylist, setNewPlaylist] = useState("");
const qc = useQueryClient(); const qc = useQueryClient();
@ -63,35 +63,37 @@ export default function PlexSidebar({
qc.invalidateQueries({ queryKey: ["plex-playlists"] }); qc.invalidateQueries({ queryKey: ["plex-playlists"] });
onOpenPlaylist(pl.id); onOpenPlaylist(pl.id);
} }
const libs = libsQ.data?.libraries ?? []; // Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters
const activeLib = libs.find((l) => l.key === library); // (faceted search) — so picking one filter narrows what the others offer. Refetches on any change.
const isMovieLib = activeLib?.kind === "movie"; // Key only on the fields plexFacets actually sends — sortDir/collectionTitle don't reach /facets,
// so keying on the whole `filters` object would re-run the heavy facet computation on every
// sort-direction toggle (or chip-title change) for an identical request.
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
const facetsQ = useQuery({ const facetsQ = useQuery({
queryKey: ["plex-facets", library], queryKey: ["plex-facets", scope, show, facetKey],
queryFn: () => api.plexFacets(library), queryFn: () => api.plexFacets(scope, filters, show),
enabled: !!library && isMovieLib, enabled: !!scope,
placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker)
}); });
const facets = facetsQ.data; const facets = facetsQ.data;
// Collections picker (searchable; the list is only fetched when none is active). // Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the
// unified scope has no single library). Only fetched while no collection is active (else the chip
// shows instead). Applying one sets the collection filter; the actual filtering already flows through
// /library + /facets by rating_key.
const [collSearch, setCollSearch] = useState(""); const [collSearch, setCollSearch] = useState("");
const collSearchDeb = useDebounced(collSearch.trim(), 300); const collSearchDeb = useDebounced(collSearch.trim(), 300);
const collectionsQ = useQuery({ const collectionsQ = useQuery({
queryKey: ["plex-collections", library, collSearchDeb], // "union" disambiguates from the editor's per-library ["plex-collections", <plex_key>] key (a search
queryFn: () => api.plexCollections(library, collSearchDeb || undefined), // term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working.
enabled: !!library && isMovieLib && !filters.collection, queryKey: ["plex-collections", "union", collSearchDeb],
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
enabled: !filters.collection,
}); });
const collections = collectionsQ.data?.collections ?? []; const collections = collectionsQ.data?.collections ?? [];
// Default to the first library once loaded (or if the stored one vanished). const fCount = plexFilterCount(filters);
useEffect(() => { const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0);
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
}, [libs, library, setLibrary]);
const fCount = isMovieLib ? plexFilterCount(filters) : 0;
const activeCount =
fCount + (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
const anyActive = activeCount > 0; const anyActive = activeCount > 0;
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p }); const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
@ -100,10 +102,14 @@ export default function PlexSidebar({
const clearAll = () => { const clearAll = () => {
setFilters(EMPTY_PLEX_FILTERS); setFilters(EMPTY_PLEX_FILTERS);
setShow("all"); setShow("all");
setSort("added"); setSort("title");
}; };
const sorts = isMovieLib ? MOVIE_SORTS : SHOW_SORTS; // Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime).
// Ordered alphabetically by their localized label.
const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
);
const durBucketKey = DURATION_BUCKETS.find( const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
)?.key; )?.key;
@ -137,20 +143,19 @@ export default function PlexSidebar({
return ( 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"> <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 */} {/* Scope — one unified library, filtered to movies, shows, or both. */}
<Section label={t("plex.filter.library")}> <Section label={t("plex.filter.scope")}>
<div className="flex flex-col gap-1"> <div className="flex gap-1">
{libs.map((l) => ( {(["both", "movie", "show"] as const).map((s) => (
<button <button
key={l.key} key={s}
onClick={() => setLibrary(l.key)} onClick={() => setScope(s)}
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${ className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover" s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
}`} }`}
> >
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />} {s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
<span className="flex-1 text-left truncate">{l.title}</span> <span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
</button> </button>
))} ))}
</div> </div>
@ -205,8 +210,7 @@ export default function PlexSidebar({
</button> </button>
)} )}
{/* Watch state (movie libraries only) */} {/* Watch state — movies per-title, shows aggregated across their episodes (0052) */}
{isMovieLib && (
<Section label={t("plex.filter.show")}> <Section label={t("plex.filter.show")}>
<ChipRow> <ChipRow>
{SHOW_OPTS.map((s) => ( {SHOW_OPTS.map((s) => (
@ -216,7 +220,6 @@ export default function PlexSidebar({
))} ))}
</ChipRow> </ChipRow>
</Section> </Section>
)}
{/* Sort + direction */} {/* Sort + direction */}
<Section label={t("plex.filter.sort")}> <Section label={t("plex.filter.sort")}>
@ -228,16 +231,16 @@ export default function PlexSidebar({
))} ))}
</ChipRow> </ChipRow>
<div className="mt-1.5 flex gap-1"> <div className="mt-1.5 flex gap-1">
{(["desc", "asc"] as const).map((d) => ( {(["asc", "desc"] as const).map((d) => (
<Chip key={d} active={(filters.sortDir ?? "desc") === d} onClick={() => patch({ sortDir: d })}> <Chip key={d} active={(filters.sortDir ?? "asc") === d} onClick={() => patch({ sortDir: d })}>
{t(`plex.filter.dir.${d}`)} {t(`plex.filter.dir.${d}`)}
</Chip> </Chip>
))} ))}
</div> </div>
</Section> </Section>
{/* Metadata filters (movie libraries only) */} {/* Metadata filters — movie AND show libraries (0052); the duration bucket is movie-only. */}
{isMovieLib && ( {(
<> <>
{/* Active people / studios — set by clicking the info page (stackable). */} {/* Active people / studios — set by clicking the info page (stackable). */}
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && ( {filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
@ -268,7 +271,9 @@ export default function PlexSidebar({
</Section> </Section>
)} )}
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */} {/* Collection the active one shows as a chip; otherwise a searchable picker over the union
of all enabled libraries' collections (also settable from an item info page's "Browse
collection"). */}
<Section label={t("plex.filter.collection")}> <Section label={t("plex.filter.collection")}>
{filters.collection ? ( {filters.collection ? (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -370,7 +375,8 @@ export default function PlexSidebar({
</div> </div>
</Section> </Section>
{/* Duration buckets */} {/* Duration buckets — movie-only (a show has no single runtime) */}
{scope === "movie" && (
<Section label={t("plex.filter.duration")}> <Section label={t("plex.filter.duration")}>
<ChipRow> <ChipRow>
<Chip <Chip
@ -390,6 +396,7 @@ export default function PlexSidebar({
))} ))}
</ChipRow> </ChipRow>
</Section> </Section>
)}
{/* Added to Plex */} {/* Added to Plex */}
<Section label={t("plex.filter.added")}> <Section label={t("plex.filter.added")}>

View file

@ -13,6 +13,12 @@
}, },
"filter": { "filter": {
"library": "Bibliothek", "library": "Bibliothek",
"scope": "Bibliothek",
"scopeOpt": {
"both": "Alle",
"movie": "Filme",
"show": "Serien"
},
"show": "Anzeige", "show": "Anzeige",
"showOpt": { "showOpt": {
"all": "Alle", "all": "Alle",
@ -63,20 +69,30 @@
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.", "empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
"loadMore": "Mehr laden", "loadMore": "Mehr laden",
"watched": "Angesehen", "watched": "Angesehen",
"inProgress": "Läuft",
"play": "Abspielen", "play": "Abspielen",
"resume": "Fortsetzen", "resume": "Fortsetzen",
"openShow": "Serie öffnen", "openShow": "Serie öffnen",
"markWatched": "Als gesehen markieren", "markWatched": "Als gesehen markieren",
"markUnwatched": "Als ungesehen markieren", "markUnwatched": "Als ungesehen markieren",
"seasons": "{{count}} Staffeln", "seasons": "{{count}} Staffeln",
"playerSoon": "Player kommt bald — „{{title}}“", "unified": {
"people": { "titles": "Titel",
"match": "Personen", "episodes": "Folgen"
"actor": "Schauspieler",
"director": "Regie",
"count": "{{count}} Titel",
"added": "hinzugefügt"
}, },
"series": {
"seasons": "Staffeln",
"backToShow": "Zurück zur Serie",
"episodeCount": "{{count}} Folgen",
"playFromStart": "Von Anfang an",
"related": "Ähnliche Serien",
"markShowWatched": "Serie als gesehen",
"markShowUnwatched": "Serie als ungesehen",
"markSeasonWatched": "Staffel als gesehen",
"markSeasonUnwatched": "Staffel als ungesehen",
"addShowCollection": "Zur Sammlung"
},
"playerSoon": "Player kommt bald — „{{title}}“",
"collEditor": { "collEditor": {
"manage": "Sammlungen", "manage": "Sammlungen",
"title": "Sammlungen — {{title}}", "title": "Sammlungen — {{title}}",
@ -158,6 +174,7 @@
"customize": "Ansicht anpassen", "customize": "Ansicht anpassen",
"prefArtBg": "Dezenter Hintergrund", "prefArtBg": "Dezenter Hintergrund",
"prefCast": "Besetzung", "prefCast": "Besetzung",
"prefRelated": "Ähnliche Serien",
"stripSource": { "stripSource": {
"collection": "Sammlungen", "collection": "Sammlungen",
"imdb": "IMDb", "imdb": "IMDb",

View file

@ -13,6 +13,12 @@
}, },
"filter": { "filter": {
"library": "Library", "library": "Library",
"scope": "Library",
"scopeOpt": {
"both": "All",
"movie": "Movies",
"show": "Shows"
},
"show": "Show", "show": "Show",
"showOpt": { "showOpt": {
"all": "All", "all": "All",
@ -63,20 +69,30 @@
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.", "empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
"loadMore": "Load more", "loadMore": "Load more",
"watched": "Watched", "watched": "Watched",
"inProgress": "In progress",
"play": "Play", "play": "Play",
"resume": "Resume", "resume": "Resume",
"openShow": "Open show", "openShow": "Open show",
"markWatched": "Mark watched", "markWatched": "Mark watched",
"markUnwatched": "Mark unwatched", "markUnwatched": "Mark unwatched",
"seasons": "{{count}} seasons", "seasons": "{{count}} seasons",
"playerSoon": "Player coming soon — “{{title}}”", "unified": {
"people": { "titles": "Titles",
"match": "People", "episodes": "Episodes"
"actor": "Actor",
"director": "Director",
"count": "{{count}} titles",
"added": "added"
}, },
"series": {
"seasons": "Seasons",
"backToShow": "Back to show",
"episodeCount": "{{count}} episodes",
"playFromStart": "Play from start",
"related": "Related shows",
"markShowWatched": "Mark show watched",
"markShowUnwatched": "Mark show unwatched",
"markSeasonWatched": "Mark season watched",
"markSeasonUnwatched": "Mark season unwatched",
"addShowCollection": "Add to collection"
},
"playerSoon": "Player coming soon — “{{title}}”",
"collEditor": { "collEditor": {
"manage": "Collections", "manage": "Collections",
"title": "Collections — {{title}}", "title": "Collections — {{title}}",
@ -158,6 +174,7 @@
"customize": "Customize view", "customize": "Customize view",
"prefArtBg": "Faint backdrop", "prefArtBg": "Faint backdrop",
"prefCast": "Cast & crew", "prefCast": "Cast & crew",
"prefRelated": "Related shows",
"stripSource": { "stripSource": {
"collection": "Collections", "collection": "Collections",
"imdb": "IMDb", "imdb": "IMDb",

View file

@ -13,6 +13,12 @@
}, },
"filter": { "filter": {
"library": "Könyvtár", "library": "Könyvtár",
"scope": "Könyvtár",
"scopeOpt": {
"both": "Mind",
"movie": "Filmek",
"show": "Sorozatok"
},
"show": "Megjelenítés", "show": "Megjelenítés",
"showOpt": { "showOpt": {
"all": "Mind", "all": "Mind",
@ -63,20 +69,30 @@
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.", "empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
"loadMore": "Több betöltése", "loadMore": "Több betöltése",
"watched": "Megnézve", "watched": "Megnézve",
"inProgress": "Folyamatban",
"play": "Lejátszás", "play": "Lejátszás",
"resume": "Folytatás", "resume": "Folytatás",
"openShow": "Sorozat megnyitása", "openShow": "Sorozat megnyitása",
"markWatched": "Megnézettnek jelöl", "markWatched": "Megnézettnek jelöl",
"markUnwatched": "Nem-nézettnek jelöl", "markUnwatched": "Nem-nézettnek jelöl",
"seasons": "{{count}} évad", "seasons": "{{count}} évad",
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”", "unified": {
"people": { "titles": "Címek",
"match": "Személyek", "episodes": "Epizódok"
"actor": "Színész",
"director": "Rendező",
"count": "{{count}} cím",
"added": "hozzáadva"
}, },
"series": {
"seasons": "Évadok",
"backToShow": "Vissza a sorozathoz",
"episodeCount": "{{count}} rész",
"playFromStart": "Lejátszás az elejéről",
"related": "Kapcsolódó sorozatok",
"markShowWatched": "Egész sorozat megnézve",
"markShowUnwatched": "Sorozat jelölés visszavonása",
"markSeasonWatched": "Egész évad megnézve",
"markSeasonUnwatched": "Évad jelölés visszavonása",
"addShowCollection": "Kollekcióhoz adás"
},
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
"collEditor": { "collEditor": {
"manage": "Kollekciók", "manage": "Kollekciók",
"title": "Kollekciók — {{title}}", "title": "Kollekciók — {{title}}",
@ -158,6 +174,7 @@
"customize": "Nézet testreszabása", "customize": "Nézet testreszabása",
"prefArtBg": "Halvány háttér", "prefArtBg": "Halvány háttér",
"prefCast": "Szereplők", "prefCast": "Szereplők",
"prefRelated": "Kapcsolódó sorozatok",
"stripSource": { "stripSource": {
"collection": "Kollekciók", "collection": "Kollekciók",
"imdb": "IMDb", "imdb": "IMDb",

View file

@ -663,11 +663,25 @@ export interface PlexBrowseResult {
limit: number; limit: number;
items: PlexCard[]; items: PlexCard[];
} }
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
// also matches episodes (the grouped "Episodes" section).
export interface PlexUnifiedResult {
scope: string; // movie | show | both
total: number;
offset: number;
limit: number;
items: PlexCard[];
episodes: PlexCard[];
}
export interface PlexSeasonDetail { export interface PlexSeasonDetail {
id: string; id: string; // season rating_key
season_number: number | null; season_number: number | null;
title: string; title: string;
thumb: string; thumb: string;
episode_count: number;
status: string; // aggregate: new | in_progress | watched
resume?: PlexCard | null; // on-deck episode (last in-progress, else first unwatched)
first?: PlexCard | null; // first episode (Play from the start)
episodes: PlexCard[]; episodes: PlexCard[];
} }
export interface PlexShowDetail { export interface PlexShowDetail {
@ -678,8 +692,24 @@ export interface PlexShowDetail {
year?: number | null; year?: number | null;
thumb: string; thumb: string;
art: string; art: string;
library?: string | null; // Plex section key (for the admin collection editor)
content_rating?: string | null;
rating?: number | null;
genres: string[];
studio?: string | null;
season_count?: number | null;
imdb_rating?: number | null;
imdb_id?: string | null;
imdb_url?: string | null;
cast: PlexCastMember[];
status: string; // aggregate across all episodes
resume?: PlexCard | null;
first?: PlexCard | null;
episode_count: number;
collection_keys: string[];
}; };
seasons: PlexSeasonDetail[]; seasons: PlexSeasonDetail[];
related: PlexCard[];
} }
export interface PlexMarker { export interface PlexMarker {
@ -697,6 +727,7 @@ export interface PlexItemDetail {
playable: string; // direct | remux | transcode playable: string; // direct | remux | transcode
thumb: string; thumb: string;
art: string; art: string;
library?: string | null; // Plex section key (for the admin collection editor)
cast: PlexCastMember[]; cast: PlexCastMember[];
imdb_rating?: number | null; imdb_rating?: number | null;
imdb_id?: string | null; imdb_id?: string | null;
@ -789,11 +820,23 @@ export function plexFilterCount(f: PlexFilters): number {
(f.collection ? 1 : 0) (f.collection ? 1 : 0)
); );
} }
export interface PlexPerson { // Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
name: string; // and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are
kind: "actor" | "director"; // each caller's own concern and stay out of here).
count: number; export function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
photo?: string | null; 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.collection) u.set("collection", f.collection);
} }
export interface PlexFacets { export interface PlexFacets {
genres: { value: string; count: number }[]; genres: { value: string; count: number }[];
@ -1145,16 +1188,16 @@ export const api = {
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }), syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> => plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
req("/api/plex/libraries"), req("/api/plex/libraries"),
plexBrowse: (p: { plexLibrary: (p: {
library: string; scope: string; // movie | show | both
q?: string; q?: string;
sort?: string; sort?: string;
show?: string; show?: string;
offset?: number; offset?: number;
limit?: number; limit?: number;
filters?: PlexFilters; filters?: PlexFilters;
}): Promise<PlexBrowseResult> => { }): Promise<PlexUnifiedResult> => {
const u = new URLSearchParams({ library: p.library }); const u = new URLSearchParams({ scope: p.scope });
if (p.q) u.set("q", p.q); if (p.q) u.set("q", p.q);
if (p.sort) u.set("sort", p.sort); if (p.sort) u.set("sort", p.sort);
if (p.show && p.show !== "all") u.set("show", p.show); if (p.show && p.show !== "all") u.set("show", p.show);
@ -1162,29 +1205,28 @@ export const api = {
if (p.limit) u.set("limit", String(p.limit)); if (p.limit) u.set("limit", String(p.limit));
const f = p.filters; const f = p.filters;
if (f) { if (f) {
if (f.genres?.length) u.set("genres", f.genres.join(",")); appendPlexFilters(u, f);
if (f.genreMode === "all") u.set("genre_mode", "all"); u.set("sort_dir", f.sortDir ?? "asc"); // default ascending
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.collection) u.set("collection", f.collection);
if (f.sortDir === "asc") u.set("sort_dir", "asc");
} }
return req(`/api/plex/browse?${u.toString()}`); return req(`/api/plex/library?${u.toString()}`);
},
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
// narrows the others and the bounds match the visible result set.
plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise<PlexFacets> => {
const u = new URLSearchParams({ scope });
if (show && show !== "all") u.set("show", show);
if (f) appendPlexFilters(u, f);
return req(`/api/plex/facets?${u.toString()}`);
},
// With a library plex_key → that library's collections (editor). Without one → the union across all
// enabled libraries (the unified-scope sidebar picker).
plexCollections: (library?: string, q?: string): Promise<{ collections: PlexCollection[] }> => {
const u = new URLSearchParams();
if (library) u.set("library", library);
if (q) u.set("q", q);
const qs = u.toString();
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
}, },
plexFacets: (library: string): Promise<PlexFacets> =>
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`),
// --- Collection editing (admin only; writes back to Plex) --- // --- Collection editing (admin only; writes back to Plex) ---
plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise<PlexCollection> => plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise<PlexCollection> =>
req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }), req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),
@ -1230,6 +1272,11 @@ export const api = {
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }), req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
plexShow: (id: string): Promise<PlexShowDetail> => plexShow: (id: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}`), req(`/api/plex/show/${encodeURIComponent(id)}`),
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
plexItem: (id: string): Promise<PlexItemDetail> => plexItem: (id: string): Promise<PlexItemDetail> =>
req(`/api/plex/item/${encodeURIComponent(id)}`), req(`/api/plex/item/${encodeURIComponent(id)}`),
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => { plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {

View file

@ -0,0 +1,171 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { SlidersHorizontal } from "lucide-react";
import { useDismiss } from "./useDismiss";
import { api } from "./api";
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
// per-user display prefs (art background / cast row — same keys as the movie info page), and the
// customize menu that toggles them.
export function useDetailPrefs() {
const qc = useQueryClient();
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
plexInfoArtBg?: boolean;
plexInfoCast?: boolean;
plexInfoRelated?: boolean;
};
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
const save = (patch: Record<string, unknown>) => {
api.savePrefs(patch).catch(() => {});
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
);
};
return {
artBg,
showCast,
showRelated,
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
toggleArtBg: () => {
const next = !artBg;
setArtBg(next);
save({ plexInfoArtBg: next });
},
toggleCast: () => {
const next = !showCast;
setShowCast(next);
save({ plexInfoCast: next });
},
toggleRelated: () => {
const next = !showRelated;
setShowRelated(next);
save({ plexInfoRelated: next });
},
};
}
// A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared
// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional
// `title` gives a hover hint (e.g. "Filter by 1998"); callers that don't need it just omit it.
export 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>
);
}
// Paint the item's art as a faint FIXED backdrop on the page's <main> scroll container (HTPC-style),
// so the glass panels float over it. Cleared on unmount / when disabled / when there's no art.
export function useArtBackdrop(art: string | null | undefined, enabled: boolean) {
useLayoutEffect(() => {
const main = document.querySelector("main");
if (!main) return;
const s = main.style;
const clear = () => {
s.backgroundImage = "";
s.backgroundSize = "";
s.backgroundPosition = "";
s.backgroundRepeat = "";
s.backgroundAttachment = "";
};
if (enabled && art) {
s.backgroundImage =
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`;
s.backgroundSize = "cover";
s.backgroundPosition = "center 20%";
s.backgroundRepeat = "no-repeat";
s.backgroundAttachment = "fixed";
} else {
clear();
}
return clear;
}, [art, enabled]);
}
export function DetailCustomizeMenu({
artBg,
onToggleArtBg,
showCast,
onToggleCast,
hasCast,
showRelated,
onToggleRelated,
hasRelated,
overlay,
extra,
}: {
artBg: boolean;
onToggleArtBg: () => void;
showCast: boolean;
onToggleCast: () => void;
hasCast: boolean;
showRelated?: boolean;
onToggleRelated?: () => void;
hasRelated?: boolean;
overlay?: boolean; // white-on-dark button styling when floated over the player
extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets)
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const btnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
return (
<div className="relative shrink-0">
<button
ref={btnRef}
onClick={() => setOpen((v) => !v)}
title={t("plex.info.customize")}
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
>
<SlidersHorizontal className="w-4 h-4" />
</button>
{open && (
<div
ref={menuRef}
className="glass-menu absolute right-0 top-full z-20 mt-1 w-52 rounded-xl p-1.5 text-sm"
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
>
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
{hasCast && <PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />}
{hasRelated && onToggleRelated && (
<PrefToggle label={t("plex.info.prefRelated")} on={showRelated ?? true} onClick={onToggleRelated} />
)}
{extra}
</div>
)}
</div>
);
}
export function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
>
<span>{label}</span>
<span className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}>
<span className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`} />
</span>
</button>
);
}

View file

@ -27,6 +27,7 @@ export const LS = {
plexShow: "siftlode.plexShow", plexShow: "siftlode.plexShow",
plexSort: "siftlode.plexSort", plexSort: "siftlode.plexSort",
plexFilters: "siftlode.plexFilters", plexFilters: "siftlode.plexFilters",
plexQ: "siftlode.plexQ",
plexPlaylistLayout: "siftlode.plexPlaylistLayout", plexPlaylistLayout: "siftlode.plexPlaylistLayout",
notifHistory: "siftlode.notifications", notifHistory: "siftlode.notifications",
notifSettings: "siftlode.notifSettings", notifSettings: "siftlode.notifSettings",