diff --git a/backend/alembic/versions/0052_plex_show_meta.py b/backend/alembic/versions/0052_plex_show_meta.py new file mode 100644 index 0000000..523421c --- /dev/null +++ b/backend/alembic/versions/0052_plex_show_meta.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 4638109..5e0c915 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -956,9 +956,17 @@ class PlexLibrary(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" + __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) 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 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) + # 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( TSVECTOR, Computed( "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')", persisted=True, ), diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py index 034d6d4..54fbb28 100644 --- a/backend/app/plex/client.py +++ b/backend/app/plex/client.py @@ -96,6 +96,18 @@ class PlexClient: mc = self._get(f"/library/metadata/{rating_key}/children") 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]: """All collections in a library section (title/summary/thumb/childCount/smart).""" mc = self._get(f"/library/sections/{section_key}/collections") diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index 7cc3059..975369c 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -230,6 +230,16 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> sh.art_key = meta.get("art") sh.child_count = meta.get("childCount") 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 db.flush() show_id = {rk: sh.id for rk, sh in shows.items()} diff --git a/backend/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py index 42a9d37..9799972 100644 --- a/backend/app/plex/watch_sync.py +++ b/backend/app/plex/watch_sync.py @@ -206,6 +206,51 @@ def push_state_to_plex( 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) ------------------------ # Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index f44b5ec..af1a177 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -11,6 +11,9 @@ import os import re import subprocess import tempfile +import threading +import time +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import quote @@ -18,7 +21,7 @@ from urllib.parse import quote import httpx from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response 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 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 { "id": sh.rating_key, "type": "show", @@ -236,9 +239,80 @@ def _show_card(sh: PlexShow) -> dict: "year": sh.year, "thumb": f"/api/plex/image/{sh.rating_key}", "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: return { "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 -@router.get("/browse") -def browse( - library: str, +def _meta_filter_conds(model, p: dict) -> list: + """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for + `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, sort: str = "added", + sort_dir: str = "desc", show: str = "all", offset: int = 0, limit: int = Query(default=40, ge=1, le=100), @@ -301,120 +414,195 @@ def browse( actors: str | None = None, studios: str | None = None, collection: str | None = None, - sort_dir: str = "desc", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - """List a library's movies (leaves) or shows, with optional FTS search + sorting + a per-user - watch-state filter (`show`: all|unwatched|in_progress|watched — movie libraries only). Movie - libraries return playable movie cards; show libraries return show cards (drill in via /show).""" - lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() - if lib is None: - raise HTTPException(status_code=404, detail="Unknown Plex library") + """Unified cross-library browse: movies + shows in ONE mixed, paginated feed, scoped to + movie|show|both, with the shared filters/search/watch-state (a show's state is the aggregate of + its episodes). On a search that also matches episodes (and shows are in scope), the matching + episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section).""" offset = max(0, offset) - model = PlexItem if lib.kind == "movie" else PlexShow - - query = db.query(model) - if lib.kind == "movie": - query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie") - # Per-user watch-state filter (movies only; a show isn't a single watch unit). - st = aliased(PlexState) - query = query.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) - 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: # 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])) + p = { + "genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings, + "year_min": year_min, "year_max": year_max, "rating_min": rating_min, + "added_within": added_within, "directors": directors, "actors": actors, + "studios": studios, "collection": collection, + } + libs = db.query(PlexLibrary).filter_by(enabled=True).all() + movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] + show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] + want_movies = scope in ("movie", "both") and bool(movie_lib_ids) + want_shows = scope in ("show", "both") and bool(show_lib_ids) ts = _to_tsquery_str(q) if q else None - tsq = None - if ts: - tsq = func.to_tsquery(_TS_CONFIG, ts) - query = query.filter(model.search_vector.op("@@")(tsq)) + tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None - 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: - order = [func.ts_rank(model.search_vector, tsq).desc()] + order = [u.c.rank.desc()] else: - if sort == "title": - col = func.lower(model.title) - elif sort == "year" and model is PlexItem: - col = PlexItem.year - elif sort == "rating" and model is PlexItem: - col = PlexItem.rating - elif sort == "duration" and model is PlexItem: - col = PlexItem.duration_s - elif sort == "release" and model is PlexItem: - col = PlexItem.originally_available_at - else: # added - col = model.added_at - asc = sort_dir == "asc" - order = [(col.asc() if asc else col.desc()).nullslast()] - order.append(model.id.desc()) + col = { + "title": func.lower(u.c.title), + "year": u.c.year, + "rating": u.c.rating, + "duration": u.c.duration_s, + "release": u.c.rel, + }.get(sort, u.c.added_at) + order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] + order.append(u.c.rk.desc()) + rows = db.query(u).order_by(*order).offset(offset).limit(limit).all() - 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": - by_item = {r.id: r for r in rows} - states = { - s.item_id: s - for s in db.query(PlexState).filter( - PlexState.user_id == user.id, PlexState.item_id.in_(list(by_item.keys()) or [0]) + episodes = [] + if tsq is not None and want_shows: + ep_st = aliased(PlexState) + eps = ( + db.query(PlexItem, ep_st, PlexShow.title.label("show_title")) + .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), ) - } - items = [_movie_card(r, states.get(r.id)) for r in rows] - else: - items = [_show_card(r) for r in rows] + .order_by(func.ts_rank(PlexItem.search_vector, tsq).desc()) + .limit(24) + .all() + ) + 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") 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), db: Session = Depends(get_db), ) -> dict: - """Available filter values for a movie library — genres + content ratings (with counts) and the - year/rating/duration bounds — so the sidebar only offers what the library actually contains.""" + """Available filter values for the unified library (scope movie|show|both), ADAPTED to the active + 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 = { "genres": [], "content_ratings": [], @@ -424,33 +612,124 @@ def facets( "duration_min": None, "duration_max": None, } - lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() - if lib is None or lib.kind != "movie": + libs = db.query(PlexLibrary).filter_by(enabled=True).all() + 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 - base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'" - genres = db.execute( - text( - f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x " - "GROUP BY g ORDER BY c DESC, g" - ), - {"lib": lib.id}, - ).all() - crs = db.execute( - text(f"SELECT content_rating, count(*) AS c {base} AND content_rating IS NOT NULL GROUP BY content_rating ORDER BY c DESC"), - {"lib": lib.id}, - ).all() - b = db.execute( - text(f"SELECT min(year), max(year), max(rating), min(duration_s), max(duration_s) {base}"), - {"lib": lib.id}, - ).first() + p = { + "genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings, + "year_min": year_min, "year_max": year_max, "rating_min": rating_min, + "duration_min": duration_min, "duration_max": duration_max, "added_within": added_within, + "directors": directors, "actors": actors, "studios": studios, "collection": collection, + } + + uid = user.id + + def wsq(query, model, ids, pp, kind_movie, with_dur): + """Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a + facet aggregation query, so the facets reflect exactly the visible result set.""" + query = query.filter(model.library_id.in_(ids), *_meta_filter_conds(model, pp)) + if kind_movie: + 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() + 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 { - "genres": [{"value": g, "count": c} for g, c in genres], - "content_ratings": [{"value": cr, "count": c} for cr, c in crs], - "year_min": b[0], - "year_max": b[1], - "rating_max": float(b[2]) if b[2] is not None else None, - "duration_min": b[3], - "duration_max": b[4], + "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 sorted(cr_counts.items(), key=lambda kv: -kv[1])], + "year_min": min(years) if years else None, + "year_max": max(years) if years else None, + "rating_max": max(ratings) if ratings else None, + "duration_min": min(durs) if durs else None, + "duration_max": max(durs) if durs else None, } @@ -458,69 +737,6 @@ def _like_escape(s: str) -> str: 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: """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.""" @@ -543,16 +759,25 @@ def _collection_card(c: PlexCollection) -> dict: @router.get("/collections") def collections( - library: str, + library: str | None = None, q: str | None = None, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - """The library's mirrored collections as cards (most-populated first). Optional name filter `q`.""" - lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() - if lib is None: - return {"collections": []} - query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id) + """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() + if lib is None: + return {"collections": []} + 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() if 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} +# 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}") def show_detail( rating_key: str, @@ -947,8 +1208,27 @@ def show_detail( ) } by_season: dict[int | None, list] = {} + all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup 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 { "show": { "id": sh.rating_key, @@ -957,20 +1237,72 @@ def show_detail( "year": sh.year, "thumb": f"/api/plex/image/{sh.rating_key}", "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": [ - { - "id": se.rating_key, - "season_number": se.season_number, - "title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"), - "thumb": f"/api/plex/image/{se.rating_key}", - "episodes": by_season.get(se.id, []), - } + _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, + "season_number": se.season_number, + "title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"), + "thumb": f"/api/plex/image/{se.rating_key}", + "episode_count": roll["episode_count"], + "status": roll["status"], + "resume": roll["resume"], + "first": roll["first"], + "episodes": cards, + } + + +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: """The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys are proxyable — this is not an open image proxy.""" @@ -1448,8 +1780,8 @@ def item_detail( "text": codec not in _BITMAP_SUBS, } ) - except (PlexError, PlexNotConfigured): - pass # metadata extras are best-effort; playback works without them + except Exception: + 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) 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, "thumb": f"/api/plex/image/{it.rating_key}", "art": f"/api/plex/image/{it.rating_key}?variant=art", + "library": _lib_key(db, it.library_id), "cast": rich.get("cast", []), "imdb_rating": rich.get("imdb_rating"), "imdb_id": rich.get("imdb_id"), @@ -1609,6 +1942,82 @@ def item_state( 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") def stream_session( rating_key: str, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a573e6..325008b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -246,9 +246,12 @@ export default function App() { const channelsView: ChannelsView = channelsViewRaw === "discovery" ? "discovery" : "subscribed"; // 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 [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added"); + const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title"); const [plexPlaylistOpen, setPlexPlaylistOpen] = useState(null); // sidebar → open a playlist // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). 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 // "0 matches") after a reload. Kept in App so it survives page switches within a session, but // 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 // there to see a specific set (the header's "without full history" link). const [channelsFilterReset, setChannelsFilterReset] = useState(0); @@ -740,8 +743,8 @@ export default function App() { {page === "plex" && meQuery.data!.plex_enabled && ( setPlexQ("")} - library={plexLib} + scope={plexScope} + setScope={setPlexScope} show={plexShowFilter} sort={plexSort} filters={plexFilters} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 02a32a7..0164b4c 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -1,18 +1,36 @@ import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react"; import { useTranslation } from "react-i18next"; -import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, CheckCircle2, Info, ListPlus, Play } from "lucide-react"; +import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { + ArrowLeft, + Check, + CheckCheck, + CheckCircle2, + Film, + Info, + Layers, + ListPlus, + Play, + RotateCcw, + Star, + Tv2, + type LucideIcon, +} from "lucide-react"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexCard, + type PlexCastMember, type PlexFilters, - type PlexPerson, + type PlexSeasonDetail, + type PlexUnifiedResult, } from "../lib/api"; import { useDebounced } from "../lib/useDebounced"; import { useHistorySubview } from "../lib/history"; +import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; +import PlexCollectionEditor from "./PlexCollectionEditor"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); @@ -22,6 +40,7 @@ const PlexPlaylistView = lazy(() => import("./PlexPlaylistView")); type Sub = | { kind: "grid" } | { kind: "show"; id: string } + | { kind: "season"; showId: string; seasonId: string } | { kind: "player"; id: string; queue?: string[] } | { kind: "info"; id: string } | { kind: "playlist"; id: number }; @@ -35,7 +54,8 @@ type Sub = type Props = { q: string; onClearSearch: () => void; - library: string; + scope: string; // movie | show | both (unified cross-library scope) + setScope: (v: string) => void; show: string; sort: string; filters: PlexFilters; @@ -57,7 +77,8 @@ function dur(n?: number | null): string { export default function PlexBrowse({ q, onClearSearch, - library, + scope, + setScope, show, sort, filters, @@ -98,13 +119,31 @@ export default function PlexBrowse({ else if (sub.view.kind === "info" && infoScrollRef.current) el.scrollTop = infoScrollRef.current; }, [sub.view.kind]); + // Backspace steps back one drill-down level (grid ← show ← season, and out of info/playlist), + // matching the browser/mouse Back. The player has its OWN Backspace handling, so we skip it here; + // and never hijack Backspace while typing in a field. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Backspace") return; + const el = document.activeElement as HTMLElement | null; + const tag = (el?.tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable) return; + if (["show", "season", "info", "playlist"].includes(sub.view.kind)) { + e.preventDefault(); + sub.back(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [sub]); + const browseQ = useInfiniteQuery({ - queryKey: ["plex-browse", library, dq, sort, show, filters], - enabled: !!library && sub.view.kind === "grid", + queryKey: ["plex-library", scope, dq, sort, show, filters], + enabled: !!scope && sub.view.kind === "grid", initialPageParam: 0, queryFn: ({ pageParam }) => - api.plexBrowse({ - library, + api.plexLibrary({ + scope, q: dq || undefined, sort, show, @@ -120,21 +159,8 @@ export default function PlexBrowse({ const items = browseQ.data?.pages.flatMap((p) => p.items) ?? []; const total = browseQ.data?.pages[0]?.total ?? 0; - - // Cast/crew whose name matches the current search → virtual cards above the grid; clicking one - // adds that person to the filter. Only meaningful for movie libraries (the endpoint returns [] else). - const peopleQ = useQuery({ - queryKey: ["plex-people", library, dq], - queryFn: () => api.plexPeople(library, dq), - enabled: !!library && dq.length >= 2 && sub.view.kind === "grid", - }); - const people = peopleQ.data?.people ?? []; - function addPerson(p: PlexPerson) { - const key = p.kind === "director" ? "directors" : "actors"; - const cur = filters[key]; - if (!cur.includes(p.name)) setFilters({ ...filters, [key]: [...cur, p.name] }); - onClearSearch(); // switch from the name search to the person filter (clears the search box) - } + // Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0. + const episodes = browseQ.data?.pages[0]?.episodes ?? []; // Infinite scroll: auto-load the next page when the sentinel scrolls into view. const sentinel = useRef(null); @@ -162,9 +188,52 @@ export default function PlexBrowse({ infoScrollRef.current = 0; // fresh info page starts at the top sub.open({ kind: "info", id: card.id }); } + // Optimistically flip a card's status in every cached library page — both the title grid (`items`) + // and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably + // BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe. + function optimisticStatus(id: string, next: string) { + qc.setQueriesData>({ queryKey: ["plex-library"] }, (old) => + old + ? { + ...old, + pages: old.pages.map((pg) => ({ + ...pg, + items: pg.items.map((it) => (it.id === id ? { ...it, status: next } : it)), + episodes: pg.episodes.map((ep) => (ep.id === id ? { ...ep, status: next } : ep)), + })), + } + : old, + ); + } async function toggleWatched(card: PlexCard) { - await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched"); - qc.invalidateQueries({ queryKey: ["plex-browse"] }); + const next = card.status === "watched" ? "new" : "watched"; + optimisticStatus(card.id, next); + await api.plexSetState(card.id, next).catch(() => {}); + qc.invalidateQueries({ queryKey: ["plex-library"] }); + } + async function toggleEpisodeWatched(ep: PlexCard) { + const next = ep.status === "watched" ? "new" : "watched"; + optimisticStatus(ep.id, next); + await api.plexSetState(ep.id, next).catch(() => {}); + qc.invalidateQueries({ queryKey: ["plex-library"] }); + } + // Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid. + // Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person + // (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed). + function applyFilter(patch: Partial) { + const merged = { ...filters } as unknown as Record; + for (const [k, v] of Object.entries(patch)) { + const cur = merged[k]; + if (Array.isArray(v) && Array.isArray(cur)) { + merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])])); + } else { + merged[k] = v; + } + } + setFilters(merged as unknown as PlexFilters); + if (patch.actors?.length || patch.directors?.length) setScope("both"); + infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to the info/show page + sub.open({ kind: "grid" }); } if (sub.view.kind === "player") { @@ -191,37 +260,34 @@ export default function PlexBrowse({ return ( sub.open({ kind: "player", id: infoId })} onPlayItem={(id) => sub.open({ kind: "player", id })} - onFilter={(patch) => { - // Clicking a metadata chip sets that filter and shows the filtered grid. Array filters - // (genres/people/studios) UNION with what's already set (so you can stack people); scalars - // replace. We push a fresh grid entry (not history.back) so browser Back returns to this - // info page instead of leaving the Plex module. - const merged = { ...filters } as unknown as Record; - for (const [k, v] of Object.entries(patch)) { - const cur = merged[k]; - if (Array.isArray(v) && Array.isArray(cur)) { - merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])])); - } else { - merged[k] = v; - } - } - setFilters(merged as unknown as PlexFilters); - infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to this info page - sub.open({ kind: "grid" }); - }} + onFilter={applyFilter} /> ); } if (sub.view.kind === "show") { + const showId = sub.view.id; return ( sub.open({ kind: "player", id: ep.id })} + onPlay={(epRk, queue) => sub.open({ kind: "player", id: epRk, queue })} + onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })} + onOpenShow={(id) => sub.open({ kind: "show", id })} + onFilter={applyFilter} + /> + ); + } + if (sub.view.kind === "season") { + const { showId, seasonId } = sub.view; + return ( + sub.open({ kind: "player", id: epRk, queue })} /> ); } @@ -232,48 +298,9 @@ export default function PlexBrowse({ {browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}

- {/* Virtual person cards for a name search — click to add them to the filter. */} - {people.length > 0 && ( -
-

{t("plex.people.match")}

-
- {people.map((p) => { - const added = (p.kind === "director" ? filters.directors : filters.actors).includes(p.name); - return ( - - ); - })} -
-
- )} - {browseQ.isLoading ? (

{t("plex.loading")}

- ) : items.length === 0 ? ( + ) : items.length === 0 && episodes.length === 0 ? ( dq && plexFilterCount(filters) > 0 ? ( // A search that comes up empty WHILE filters are active is usually the filters, not the // query — say so and offer a one-click escape, instead of a bare "No matches". @@ -290,17 +317,44 @@ export default function PlexBrowse({

{dq ? t("plex.noMatches") : t("plex.empty")}

) ) : ( -
- {items.map((c) => ( - onCard(c)} - onInfo={() => onInfo(c)} - onToggleWatched={toggleWatched} - /> - ))} -
+ <> + {/* Titles grid — movies + shows mixed (visually tagged). */} + {items.length > 0 && ( + <> + {episodes.length > 0 && ( +

{t("plex.unified.titles")}

+ )} +
+ {items.map((c) => ( + onCard(c)} + onInfo={() => onInfo(c)} + onToggleWatched={toggleWatched} + /> + ))} +
+ + )} + {/* Episodes section (Proposal 3) — matching episodes on a search, kept out of the title grid. */} + {episodes.length > 0 && ( +
+

{t("plex.unified.episodes")}

+
+ {episodes.map((ep) => ( + onCard(ep)} + onToggleWatched={() => toggleEpisodeWatched(ep)} + /> + ))} +
+
+ )} + )}
@@ -358,16 +412,14 @@ function PlexPosterCard({ decoding="async" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" /> - {/* Hover affordance: what a click does (play / resume / open show). */} + {/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */}
- {isPlayable ? ( -
- - {inProgress ? t("plex.resume") : t("plex.play")} -
- ) : ( - {t("plex.openShow")} - )} +
+ + + {card.type === "show" ? t("plex.openShow") : inProgress ? t("plex.resume") : t("plex.play")} + +
{/* Quick watched toggle (movies/episodes), on hover. */} {isPlayable && ( @@ -397,7 +449,7 @@ function PlexPosterCard({ )} {/* Watched badge when not hovering (the toggle replaces it on hover). */} {card.status === "watched" && ( - + {t("plex.watched")} )} @@ -406,34 +458,51 @@ function PlexPosterCard({ {t("plex.seasons", { count: card.season_count })} )} + {/* Aggregate in-progress badge for a partially-watched show. */} + {card.type === "show" && card.status === "in_progress" && ( + + {t("plex.inProgress")} + + )} {card.playable && ( )} + {/* Type tag — tell a movie card from a show card at a glance (unified feed). */} + + {card.type === "show" ? : } + {pct > 0 && (
)}
-
{card.title}
-
{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}
+
+
{card.title}
+
{[card.year, dur(card.duration_seconds)].filter(Boolean).join(" · ")}
+
); } function PlexInfoView({ id, - library, onBack, onPlay, onPlayItem, onFilter, }: { id: string; - library: string; onBack: () => void; onPlay: () => void; onPlayItem: (id: string) => void; @@ -459,7 +528,7 @@ function PlexInfoView({ void; label: string }) { + return ( + + ); +} + +function ActionBtn({ + onClick, + icon: Icon, + label, + sub, + primary, + disabled, +}: { + onClick: () => void; + icon: LucideIcon; + label: string; + sub?: string; + primary?: boolean; + disabled?: boolean; +}) { + return ( + + ); +} + +function SeasonCard({ + se, + onOpen, + onToggleWatched, + onAdd, +}: { + se: PlexSeasonDetail; + onOpen: () => void; + onToggleWatched?: () => void; + onAdd?: () => void; +}) { + const { t } = useTranslation(); + const watched = se.status === "watched"; + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(); + } + }} + className="relative aspect-[2/3] rounded-xl overflow-hidden bg-card border border-border cursor-pointer" + > + +
+
+ + {t("plex.openShow")} +
+
+ {/* Quick watched toggle (whole season), on hover. */} + {onToggleWatched && ( + + )} + {/* Add whole season to a playlist, on hover. */} + {onAdd && ( + + )} + {watched && ( + + {t("plex.watched")} + + )} + {se.status === "in_progress" && ( + + {t("plex.inProgress")} + + )} + + {t("plex.series.episodeCount", { count: se.episode_count })} + +
+
+ {se.title} +
+
+ ); +} + +function CastStrip({ cast, onFilter }: { cast: PlexCastMember[]; onFilter?: (patch: Partial) => void }) { + const { t } = useTranslation(); + return ( +
+

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

+
+ {cast.map((c, i) => { + const inner = ( + <> +
+ {c.thumb ? ( + + ) : ( +
{c.name.charAt(0)}
+ )} +
+
+ {c.name} +
+ {c.role &&
{c.role}
} + + ); + return onFilter ? ( + + ) : ( +
+ {inner} +
+ ); + })} +
+
+ ); +} + +function RelatedStrip({ related, onOpen }: { related: PlexCard[]; onOpen: (id: string) => void }) { + const { t } = useTranslation(); + return ( +
+

{t("plex.series.related")}

+
+ {related.map((r) => ( + + ))} +
+
+ ); +} + +function EpisodeCard({ + ep, + onPlay, + onAdd, + onToggleWatched, + withShowTitle, +}: { + ep: PlexCard; + onPlay: () => void; + onAdd?: () => void; + onToggleWatched?: () => void; + withShowTitle?: boolean; +}) { + const { t } = useTranslation(); + const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; + const pct = + inProgress && ep.duration_seconds + ? Math.min(100, Math.round(((ep.position_seconds ?? 0) / ep.duration_seconds) * 100)) + : 0; + return ( +
+
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onPlay(); + } + }} + className="relative block w-full aspect-video rounded-xl overflow-hidden bg-card border border-border text-left cursor-pointer" + > + +
+ +
+ {/* Quick watched toggle (single episode), on hover. */} + {onToggleWatched && ( + + )} + {ep.status === "watched" && ( + + + + )} + {pct > 0 && ( +
+
+
+ )} +
+
+
+ {withShowTitle && ep.show_title && ( +
+ {ep.show_title} + {ep.season_number != null && ep.episode_number != null + ? ` · S${ep.season_number}·E${ep.episode_number}` + : ""} +
+ )} +
+ {!withShowTitle && {ep.episode_number}.} + {ep.title} +
+
{dur(ep.duration_seconds)}
+
+ {onAdd && ( + + )} +
+
+ ); +} + function PlexShowView({ showId, onBack, onPlay, + onOpenSeason, + onOpenShow, + onFilter, }: { showId: string; onBack: () => void; - onPlay: (c: PlexCard) => void; + onPlay: (epRk: string, queue: string[]) => void; + onOpenSeason: (seasonId: string) => void; + onOpenShow: (id: string) => void; + onFilter?: (patch: Partial) => void; }) { const { t } = useTranslation(); + const qc = useQueryClient(); + const isAdmin = qc.getQueryData<{ role?: string }>(["me"])?.role === "admin"; const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) }); const d = q.data; - // "Add to playlist" dialog target (single episode / whole season / whole show); null = closed. const [addTarget, setAddTarget] = useState(null); - const allEpisodeKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id)); + const [collOpen, setCollOpen] = useState(false); + const [busy, setBusy] = useState(false); + + const show = d?.show; + const showLib = show?.library ?? undefined; + const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id)); + const watched = show?.status === "watched"; + const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs(); + useArtBackdrop(show?.art, artBg); + async function markAll(w: boolean) { + setBusy(true); + try { + await api.plexShowState(showId, w); + } finally { + setBusy(false); + } + qc.invalidateQueries({ queryKey: ["plex-show", showId] }); + qc.invalidateQueries({ queryKey: ["plex-library"] }); + } + async function markSeason(seasonRk: string, w: boolean) { + await api.plexSeasonState(seasonRk, w).catch(() => {}); + qc.invalidateQueries({ queryKey: ["plex-show", showId] }); + qc.invalidateQueries({ queryKey: ["plex-library"] }); + } return (
- + - {q.isLoading || !d ? ( + {q.isLoading || !d || !show ? (

{t("plex.loading")}

) : ( <> -
- -
-

{d.show.title}

- {d.show.year &&

{d.show.year}

} - {d.show.summary && ( -

{d.show.summary}

- )} - {allEpisodeKeys.length > 0 && ( - - )} + {/* Hero */} +
+
+ 0} + showRelated={showRelated} + onToggleRelated={toggleRelated} + hasRelated={d.related.length > 0} + />
-
- - {d.seasons.map((se) => ( -
-
-

{se.title}

- {se.episodes.length > 0 && ( + +
+

{show.title}

+
+ {show.year != null && ( + onFilter({ yearMin: show.year, yearMax: show.year }) : undefined}> + {show.year} + + )} + {show.content_rating && ( + onFilter({ contentRatings: [show.content_rating!] }) : undefined}> + · {show.content_rating} + + )} + {show.season_count != null && · {t("plex.seasons", { count: show.season_count })}} + {show.imdb_rating != null && ( )}
-
- {se.episodes.map((ep) => { - const inProgress = (ep.position_seconds ?? 0) > 0 && ep.status !== "watched"; - return ( -
- - -
- ); - })} + {show.genres.length > 0 && ( +
+ {show.genres.map((g) => ( + onFilter({ genres: [g] }) : undefined} + > + {g} + + ))} +
+ )} + {show.summary && ( +

{show.summary}

+ )} +
+ {show.resume && ( + onPlay(show.resume!.id, allKeys)} + icon={Play} + label={show.status === "new" ? t("plex.play") : t("plex.resume")} + sub={epLabel(show.resume)} + /> + )} + {show.first && show.status !== "new" && ( + onPlay(show.first!.id, allKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} /> + )} + markAll(!watched)} + disabled={busy} + icon={watched ? CheckCheck : Check} + label={watched ? t("plex.series.markShowUnwatched") : t("plex.series.markShowWatched")} + /> + {allKeys.length > 0 && ( + setAddTarget({ kind: "group", ratingKeys: allKeys, title: show.title })} + icon={ListPlus} + label={t("plex.playlist.addShow")} + /> + )} + {isAdmin && showLib && ( + setCollOpen(true)} icon={Layers} label={t("plex.series.addShowCollection")} /> + )}
- ))} +
+ + {/* Seasons — in a glassy panel like the movie info-page strips. */} +
+

{t("plex.series.seasons")}

+
+ {d.seasons.map((se) => ( + onOpenSeason(se.id)} + onToggleWatched={() => markSeason(se.id, se.status !== "watched")} + onAdd={ + se.episodes.length > 0 + ? () => + setAddTarget({ + kind: "group", + ratingKeys: se.episodes.map((e) => e.id), + title: `${show.title} — ${se.title}`, + }) + : undefined + } + /> + ))} +
+
+ + {show.cast.length > 0 && showCast && } + {d.related.length > 0 && showRelated && } + + )} + + {addTarget && setAddTarget(null)} />} + {collOpen && show && showLib && ( + setCollOpen(false)} + onChanged={() => qc.invalidateQueries({ queryKey: ["plex-show", showId] })} + /> + )} +
+ ); +} + +function PlexSeasonView({ + showId, + seasonId, + onBack, + onPlay, +}: { + showId: string; + seasonId: string; + onBack: () => void; + onPlay: (epRk: string, queue: string[]) => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const q = useQuery({ queryKey: ["plex-show", showId], queryFn: () => api.plexShow(showId) }); + const d = q.data; + const se = d?.seasons.find((s) => s.id === seasonId); + const [addTarget, setAddTarget] = useState(null); + const [busy, setBusy] = useState(false); + + const seasonKeys = se?.episodes.map((e) => e.id) ?? []; + const watched = se?.status === "watched"; + const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs(); + useArtBackdrop(d?.show.art, artBg); + async function markAll(w: boolean) { + setBusy(true); + try { + await api.plexSeasonState(seasonId, w); + } finally { + setBusy(false); + } + qc.invalidateQueries({ queryKey: ["plex-show", showId] }); + qc.invalidateQueries({ queryKey: ["plex-library"] }); + } + async function markEpisode(ep: PlexCard) { + await api.plexSetState(ep.id, ep.status === "watched" ? "new" : "watched").catch(() => {}); + qc.invalidateQueries({ queryKey: ["plex-show", showId] }); + qc.invalidateQueries({ queryKey: ["plex-library"] }); + } + + return ( +
+ + + {q.isLoading || !d || !se ? ( +

{t("plex.loading")}

+ ) : ( + <> +
+
+ +
+ +
+ +

{se.title}

+

{t("plex.series.episodeCount", { count: se.episode_count })}

+
+ {se.resume && ( + onPlay(se.resume!.id, seasonKeys)} + icon={Play} + label={se.status === "new" ? t("plex.play") : t("plex.resume")} + sub={epLabel(se.resume)} + /> + )} + {se.first && se.status !== "new" && ( + onPlay(se.first!.id, seasonKeys)} icon={RotateCcw} label={t("plex.series.playFromStart")} /> + )} + markAll(!watched)} + disabled={busy} + icon={watched ? CheckCheck : Check} + label={watched ? t("plex.series.markSeasonUnwatched") : t("plex.series.markSeasonWatched")} + /> + {seasonKeys.length > 0 && ( + + setAddTarget({ kind: "group", ratingKeys: seasonKeys, title: `${d.show.title} — ${se.title}` }) + } + icon={ListPlus} + label={t("plex.playlist.addSeason")} + /> + )} +
+
+
+ +
+ {se.episodes.map((ep) => ( + onPlay(ep.id, seasonKeys)} + onAdd={() => setAddTarget({ kind: "single", ratingKey: ep.id, title: ep.title })} + onToggleWatched={() => markEpisode(ep)} + /> + ))} +
)} diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx index 0f300b1..fe657e6 100644 --- a/frontend/src/components/PlexCollectionEditor.tsx +++ b/frontend/src/components/PlexCollectionEditor.tsx @@ -50,7 +50,7 @@ export default function PlexCollectionEditor({ const refresh = () => { qc.invalidateQueries({ queryKey: ["plex-collections"] }); qc.invalidateQueries({ queryKey: ["plex-item", item.id] }); - qc.invalidateQueries({ queryKey: ["plex-browse"] }); + qc.invalidateQueries({ queryKey: ["plex-library"] }); onChanged(); }; diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 248a8e8..691fe92 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -1,19 +1,9 @@ -import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; -import { useDismiss } from "../lib/useDismiss"; -import { - Check, - ExternalLink, - FolderPlus, - ListPlus, - Play, - RotateCcw, - SlidersHorizontal, - Star, - X, -} from "lucide-react"; +import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; +import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; @@ -38,26 +28,6 @@ type Props = { 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 {children}; - return ( - - ); -} - function fmtDur(s?: number | null): string { if (!s) return ""; const h = Math.floor(s / 3600); @@ -78,11 +48,12 @@ export default function PlexInfo({ const { t } = useTranslation(); 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 }>(["me"])?.preferences ?? - {}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean; plexHiddenStripSources?: string[] }; - const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false); - const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false); + {}) as { plexHiddenStripSources?: string[] }; // 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. const [hiddenSources, setHiddenSources] = useState( @@ -98,52 +69,16 @@ export default function PlexInfo({ const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin"; const [editorOpen, setEditorOpen] = useState(false); const [playlistOpen, setPlaylistOpen] = useState(false); - const [customizing, setCustomizing] = useState(false); - const custBtnRef = useRef(null); - const custMenuRef = useRef(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 - // container (
) so it fills the content area and stays put while the info page scrolls over it - // (like the body's ambient pools). Scoped to
→ never covers the nav/filter sidebars. A soft - // 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) => { - api.savePrefs(patch).catch(() => {}); - qc.setQueryData<{ preferences?: Record } | undefined>(["me"], (m) => - m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m, - ); - }; + // Faint art backdrop, HTPC-style (page variant only) — the shared hook paints the item's art as a + // fixed background on
and clears it on unmount / when disabled / in the overlay variant. + useArtBackdrop(detail.art, variant !== "overlay" && artBg); const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched"; const setState = async (status: "new" | "watched") => { await api.plexSetState(detail.id, status).catch(() => {}); qc.invalidateQueries({ queryKey: ["plex-item", detail.id] }); - qc.invalidateQueries({ queryKey: ["plex-browse"] }); + qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: ["plex-show"] }); onStateChange?.(); }; @@ -180,48 +115,22 @@ export default function PlexInfo({

)}
-
- - {customizing && ( -
- { - setArtBg((v) => !v); - savePref({ plexInfoArtBg: !artBg }); - }} - /> - { - setShowCast((v) => !v); - savePref({ plexInfoCast: !showCast }); - }} - /> - {presentSources.map((src) => ( - toggleSource(src)} - /> - ))} -
- )} -
+ 0} + extra={presentSources.map((src) => ( + toggleSource(src)} + /> + ))} + /> {overlay && onClose && ( - ); -} diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 4fb7db2..68e4ddd 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -489,7 +489,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) { old ? { ...old, position_seconds: pos } : old, ); } - qc.invalidateQueries({ queryKey: ["plex-browse"] }); + qc.invalidateQueries({ queryKey: ["plex-library"] }); qc.invalidateQueries({ queryKey: ["plex-show"] }); }; }, [id, qc]); diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index a95fac2..dce76cb 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; 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. type Props = { - library: string; - setLibrary: (v: string) => void; + scope: string; // movie | show | both (unified cross-library scope) + setScope: (v: string) => void; show: string; setShow: (v: string) => void; sort: string; @@ -27,7 +27,8 @@ type Props = { const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] 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 ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; // 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({ - library, - setLibrary, + scope, + setScope, show, setShow, sort, @@ -51,7 +52,6 @@ export default function PlexSidebar({ onToggleCollapse, }: Props) { const { t } = useTranslation(); - const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries }); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); const qc = useQueryClient(); @@ -63,35 +63,37 @@ export default function PlexSidebar({ qc.invalidateQueries({ queryKey: ["plex-playlists"] }); onOpenPlaylist(pl.id); } - const libs = libsQ.data?.libraries ?? []; - const activeLib = libs.find((l) => l.key === library); - const isMovieLib = activeLib?.kind === "movie"; - + // Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters + // (faceted search) — so picking one filter narrows what the others offer. Refetches on any change. + // 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({ - queryKey: ["plex-facets", library], - queryFn: () => api.plexFacets(library), - enabled: !!library && isMovieLib, + queryKey: ["plex-facets", scope, show, facetKey], + queryFn: () => api.plexFacets(scope, filters, show), + enabled: !!scope, + placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker) }); 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 collSearchDeb = useDebounced(collSearch.trim(), 300); const collectionsQ = useQuery({ - queryKey: ["plex-collections", library, collSearchDeb], - queryFn: () => api.plexCollections(library, collSearchDeb || undefined), - enabled: !!library && isMovieLib && !filters.collection, + // "union" disambiguates from the editor's per-library ["plex-collections", ] key (a search + // term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working. + queryKey: ["plex-collections", "union", collSearchDeb], + queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), + enabled: !filters.collection, }); const collections = collectionsQ.data?.collections ?? []; - // Default to the first library once loaded (or if the stored one vanished). - useEffect(() => { - if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key); - }, [libs, library, setLibrary]); - - const fCount = isMovieLib ? plexFilterCount(filters) : 0; - const activeCount = - fCount + (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0); + const fCount = plexFilterCount(filters); + const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0); const anyActive = activeCount > 0; const patch = (p: Partial) => setFilters({ ...filters, ...p }); @@ -100,10 +102,14 @@ export default function PlexSidebar({ const clearAll = () => { setFilters(EMPTY_PLEX_FILTERS); 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( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; @@ -137,20 +143,19 @@ export default function PlexSidebar({ return (