diff --git a/VERSION b/VERSION
index 64c3512..0f1a7df 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.36.4
\ No newline at end of file
+0.37.0
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
{t("plex.people.match")}
-{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")}
) ) : ( -{t("plex.loading")}
) : ( <> -{d.show.year}
} - {d.show.summary && ( -{d.show.summary}
- )} - {allEpisodeKeys.length > 0 && ( - - )} + {/* Hero */} +{show.summary}
+ )} +{t("plex.loading")}
+ ) : ( + <> +{t("plex.series.episodeCount", { count: se.episode_count })}
+