diff --git a/VERSION b/VERSION
index f91d144a..67d5f802 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.71.0
\ No newline at end of file
+0.72.0
\ No newline at end of file
diff --git a/backend/alembic/versions/0073_saved_view_source.py b/backend/alembic/versions/0073_saved_view_source.py
new file mode 100644
index 00000000..6b2a9061
--- /dev/null
+++ b/backend/alembic/versions/0073_saved_view_source.py
@@ -0,0 +1,39 @@
+"""saved_views.source (ask 4: per-module saved views)
+
+Saved "smart views" were feed-only; the Plex module now has them too. Add a `source` discriminator
+(default "feed" so every existing row stays a feed view) and widen the per-user unique name constraint
+to (user_id, source, name) so a feed view and a Plex view can share a name. `source` is NOT NULL with a
+server_default, so the ADD is a metadata-only rewrite-free change; the constraint swap is quick on a
+small per-user table.
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0073_saved_view_source"
+down_revision: Union[str, None] = "0072_plex_person_bio"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "saved_views",
+ sa.Column("source", sa.String(length=16), nullable=False, server_default="feed"),
+ )
+ op.drop_constraint("uq_user_saved_view", "saved_views", type_="unique")
+ op.create_unique_constraint(
+ "uq_user_source_saved_view", "saved_views", ["user_id", "source", "name"]
+ )
+
+
+def downgrade() -> None:
+ # LOSSY / effectively forward-only: upgrade lets a user hold a feed view and a plex view of the same
+ # name, so re-creating the narrow (user_id, name) constraint can raise IntegrityError on such a pair.
+ # De-dup by dropping the non-feed rows first (they can't exist in the pre-0073 schema anyway) so the
+ # narrow constraint re-applies cleanly for the drift test's downgrade→re-upgrade cycle.
+ op.execute("DELETE FROM saved_views WHERE source <> 'feed'")
+ op.drop_constraint("uq_user_source_saved_view", "saved_views", type_="unique")
+ op.create_unique_constraint("uq_user_saved_view", "saved_views", ["user_id", "name"])
+ op.drop_column("saved_views", "source")
diff --git a/backend/app/models.py b/backend/app/models.py
index e449b185..18238538 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -646,19 +646,23 @@ class PlaylistItem(Base):
class SavedView(Base, TimestampMixin, UpdatedAtMixin):
- """A per-user named snapshot of the feed's filter/sort state (a "smart view").
+ """A per-user named snapshot of a module's filter/sort state (a "smart view").
- `filters` is the serialized FeedFilters JSON the frontend applies with one click.
- `position` orders them in the sidebar (drag-reorder); `is_default` marks the one view
- applied automatically on load — at most one per user, enforced by the API on set."""
+ `source` names the module the view belongs to ("feed" | "plex"); `filters` is that module's
+ serialized filter JSON the frontend applies with one click. `position` orders them in the sidebar
+ (drag-reorder, per source); `is_default` marks the one view applied automatically on load — at most
+ one per (user, source), enforced by the API on set. The name is unique per (user, source)."""
__tablename__ = "saved_views"
- __table_args__ = (UniqueConstraint("user_id", "name", name="uq_user_saved_view"),)
+ __table_args__ = (
+ UniqueConstraint("user_id", "source", "name", name="uq_user_source_saved_view"),
+ )
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE")
)
+ source: Mapped[str] = mapped_column(String(16), default="feed", server_default="feed")
name: Mapped[str] = mapped_column(String(80))
filters: Mapped[dict] = mapped_column(JSON)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py
index 71734322..f3c3b3fd 100644
--- a/backend/app/plex/query.py
+++ b/backend/app/plex/query.py
@@ -6,7 +6,7 @@ Pure query fragments — every function returns SQLAlchemy expressions or a para
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
-from sqlalchemy import and_, case, func, or_, type_coerce
+from sqlalchemy import and_, case, func, or_, select, type_coerce
from sqlalchemy.dialects.postgresql import JSONB
TS_CONFIG = "public.unaccent_simple"
@@ -41,6 +41,58 @@ def genres_col(model, lang: str):
return model.genres
+def top_art_keys(
+ db, model, lib_ids: list[int], key_col, keys: list[str], *, per: int = 4, extra_conds=()
+) -> dict[str, list[str]]:
+ """For each value in `keys` (a member of the JSONB array `key_col` — e.g. a genre value or a
+ collection rating_key), the rating_keys of its top `per` items (best-rated, then most-recent) as
+ {key: [rating_key, …]}. ONE windowed query: unnest the array, rank within each key, take the top.
+ The caller renders posters from /api/plex/image/{rating_key}, so only the keys are returned."""
+ if not lib_ids or not keys:
+ return {}
+ k = func.jsonb_array_elements_text(key_col).label("k")
+ expanded = (
+ select(model.rating_key.label("rk"), model.rating.label("r"), model.added_at.label("a"), k)
+ .where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array", *extra_conds)
+ .subquery()
+ )
+ ranked = select(
+ expanded.c.rk,
+ expanded.c.k,
+ func.row_number()
+ .over(
+ partition_by=expanded.c.k,
+ order_by=(expanded.c.r.desc().nullslast(), expanded.c.a.desc().nullslast()),
+ )
+ .label("rn"),
+ ).subquery()
+ out: dict[str, list[str]] = {}
+ for rk, key in db.execute(
+ select(ranked.c.rk, ranked.c.k)
+ .where(ranked.c.k.in_(keys), ranked.c.rn <= per)
+ .order_by(ranked.c.k, ranked.c.rn)
+ ).all():
+ out.setdefault(key, []).append(rk)
+ return out
+
+
+def runtime_by_key(db, model, lib_ids: list[int], key_col, keys: list[str]) -> dict[str, int]:
+ """Total runtime (sum of duration_s, seconds) of the items under each key in `keys` (a member of the
+ JSONB array `key_col`), as {key: seconds}. ONE grouped query. Used for a collection tile's runtime."""
+ if not lib_ids or not keys:
+ return {}
+ k = func.jsonb_array_elements_text(key_col).label("k")
+ exp = (
+ select(func.coalesce(model.duration_s, 0).label("d"), k)
+ .where(model.library_id.in_(lib_ids), func.jsonb_typeof(key_col) == "array")
+ .subquery()
+ )
+ rows = db.execute(
+ select(exp.c.k, func.sum(exp.c.d)).where(exp.c.k.in_(keys)).group_by(exp.c.k)
+ ).all()
+ return {key: int(total or 0) for key, total in rows}
+
+
def vector_col(model, lang: str):
# RANKING vector. In HU we rank by the Hungarian vector; a row matched only via its English vector
# (see `vector_match`) gets rank 0 and sorts last, which is the intended HU-first ordering.
@@ -142,8 +194,10 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list:
conds.append(model.studio.in_(stds))
for kw in split_csv(p.get("keywords")): # AND — TMDB keyword chips from the detail page (E5 S5)
conds.append(model.keywords.contains([kw]))
- if p.get("collection"):
- conds.append(model.collection_keys.contains([p["collection"]]))
+ csel = split_csv(p.get("collections"))
+ if csel: # OR (any) or AND (all) — a title can belong to several collections, mirroring genre_mode
+ cc = [model.collection_keys.contains([c]) for c in csel]
+ conds.append(and_(*cc) if p.get("collection_mode") == "all" else or_(*cc))
return conds
@@ -174,7 +228,8 @@ class LibraryFilters:
)
studios: str | None = None
keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral
- collection: str | None = None
+ collections: str | None = None # CSV of collection rating_keys
+ collection_mode: str = "any" # how multiple collections combine: any (OR, default) | all (AND)
def as_dict(self) -> dict:
return {
@@ -192,7 +247,8 @@ class LibraryFilters:
"actor_mode": self.actor_mode,
"studios": self.studios,
"keywords": self.keywords,
- "collection": self.collection,
+ "collections": self.collections,
+ "collection_mode": self.collection_mode,
}
diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py
index 36540b94..84c4aff0 100644
--- a/backend/app/routes/plex/collections.py
+++ b/backend/app/routes/plex/collections.py
@@ -9,13 +9,33 @@ from app.auth import admin_user, current_user
from app.db import get_db
from app.models import (
PlexCollection,
+ PlexItem,
PlexLibrary,
+ PlexShow,
User,
)
+from app.plex import rollup
from app.plex import sync as plex_sync
+from app.plex.cards import movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
-from app.plex.query import like_escape
+from app.plex.query import like_escape, runtime_by_key, top_art_keys
+from app.routes.plex._shared import _content_libs
+
+# The most-restrictive → least ordering used to pick a collection's effective content rating from its
+# members (Plex shows one cert on the collection; we don't store it, so derive it).
+_CERT_RANK = {
+ "G": 1, "TV-Y": 1, "TV-Y7": 1, "TV-G": 1,
+ "PG": 2, "TV-PG": 2,
+ "PG-13": 3, "TV-14": 3,
+ "R": 4, "TV-MA": 4,
+ "NC-17": 5,
+}
+
+
+def _collection_cert(certs: list[str | None]) -> str | None:
+ ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
+ return max(ranked, key=lambda x: x[1])[0] if ranked else None
router = APIRouter(prefix="/api/plex", tags=["plex"])
@@ -93,7 +113,74 @@ def collections(
func.lower(PlexCollection.title),
)
).scalars().all()
- return {"collections": [_collection_card(c) for c in rows]}
+ # A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW
+ # members (a collection lives in one library, so exactly one side has art) — a show collection would
+ # otherwise render blank; the collection's own poster is the next fallback, then the colour hash.
+ # Runtime is movie-only (shows have no single duration).
+ movie_ids, show_ids = _content_libs(db)
+ keys = [c.rating_key for c in rows]
+ art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
+ art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
+ runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
+
+ def _art(c: PlexCollection) -> list[str]:
+ member = art_m.get(c.rating_key) or art_s.get(c.rating_key)
+ return member or ([c.rating_key] if c.thumb_key else [])
+
+ return {
+ "collections": [
+ {
+ **_collection_card(c),
+ "art": _art(c),
+ "total_runtime_s": runtime.get(c.rating_key, 0),
+ }
+ for c in rows
+ ]
+ }
+
+
+@router.get("/collections/{rating_key}")
+def collection_detail(
+ rating_key: str,
+ user: User = Depends(current_user),
+ db: Session = Depends(get_db),
+) -> dict:
+ """One collection's hero data + its members (movies + shows), ordered by release year then title —
+ the Collection info page. Read-only here; edits go through the admin mutation endpoints below."""
+ col = by_rating_key(db, PlexCollection, rating_key)
+ if col is None:
+ raise HTTPException(status_code=404, detail="Unknown collection")
+ movies = db.execute(
+ select(PlexItem)
+ .where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
+ .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
+ ).scalars().all()
+ shows = db.execute(
+ select(PlexShow)
+ .where(PlexShow.collection_keys.contains([rating_key]))
+ .order_by(PlexShow.year.asc().nullslast(), func.lower(PlexShow.title))
+ ).scalars().all()
+ mstate = rollup.item_state_map(db, user.id, [m.id for m in movies])
+ sstate = rollup.state_map(db, user.id, [s.id for s in shows])
+ # Merge the two type-homogeneous lists into ONE member list ordered by (year, title).
+ tagged = [(m.year, (m.title or "").lower(), "movie", m) for m in movies] + [
+ (s.year, (s.title or "").lower(), "show", s) for s in shows
+ ]
+ tagged.sort(key=lambda t: (t[0] is None, t[0] or 0, t[1]))
+ items = [
+ movie_card(o, mstate.get(o.id)) if kind == "movie" else show_card(o, sstate.get(o.id, "new"))
+ for _, _, kind, o in tagged
+ ]
+ years = [y for y, _, _, _ in tagged if y is not None]
+ certs = [m.content_rating for m in movies] + [s.content_rating for s in shows]
+ return {
+ **_collection_card(col),
+ "content_rating": _collection_cert(certs),
+ "year_min": min(years) if years else None,
+ "year_max": max(years) if years else None,
+ "total_runtime_s": sum(m.duration_s or 0 for m in movies),
+ "items": items,
+ }
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
@@ -105,6 +192,41 @@ def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection:
return col
+def _reject_duplicate_name(
+ db: Session, library_id: int, title: str, exclude_rk: str | None = None
+) -> None:
+ """No two collections in a section may share a name: Plex CONFLATES same-named collections'
+ membership (both rating_keys resolve to one merged member list — a real footgun), so forbid it up
+ front on create + rename rather than mirror the mess."""
+ dup = db.execute(
+ select(PlexCollection).where(
+ PlexCollection.library_id == library_id,
+ func.lower(PlexCollection.title) == title.lower(),
+ )
+ ).scalars().first()
+ if dup is not None and dup.rating_key != exclude_rk:
+ raise HTTPException(status_code=409, detail="A collection with this name already exists")
+
+
+def _write_or_pending_error(wrote: bool, e: Exception) -> HTTPException:
+ """Map a PlexError raised inside a mutate-then-resync block to the honest message. If the mutating
+ call itself failed (``wrote`` False) the write never landed → "Plex write failed". If the write
+ succeeded but the follow-up resync READ (Plex metadata/children) failed, the change IS on the Plex
+ server → don't claim a write failure; say the local copy will reconcile on the next sync.
+
+ Both use 422, NOT a 5xx: the frontend treats 502/503/504 as "gateway unreachable" and raises a
+ sticky offline banner (see api/core.ts RETRIABLE_GATEWAY). A 422 surfaces the detail as an ordinary
+ action-error toast, which is what both of these are — the app answered, it didn't go unreachable."""
+ if wrote:
+ detail = (
+ "Collection updated in Plex, but refreshing the local copy failed; "
+ f"it will reconcile on the next sync ({e})"
+ )
+ else:
+ detail = f"Plex write failed: {e}"
+ return HTTPException(status_code=422, detail=detail)
+
+
class CreateCollectionIn(BaseModel):
library: str = ""
title: str = ""
@@ -125,20 +247,28 @@ def create_collection(
raise HTTPException(
status_code=400, detail="title and item_rating_key are required"
)
- lib = db.execute(
- select(PlexLibrary).where(PlexLibrary.plex_key == library)
- ).scalars().first()
+ if library:
+ lib = db.execute(
+ select(PlexLibrary).where(PlexLibrary.plex_key == library)
+ ).scalars().first()
+ else:
+ # The "New collection" flow only knows the seed title → derive its library.
+ seed_item = by_rating_key(db, PlexItem, seed) or by_rating_key(db, PlexShow, seed)
+ lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None
if lib is None:
raise HTTPException(status_code=404, detail="Unknown library")
+ _reject_duplicate_name(db, lib.id, title)
+ wrote = False
try:
with PlexClient(db) as plex:
new = plex.create_collection(lib.plex_key, title, seed)
+ wrote = True
col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey")))
col.editable = True
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
- raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
+ raise _write_or_pending_error(wrote, e)
return _collection_card(col)
@@ -151,14 +281,16 @@ def collection_add_item(
) -> dict:
col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id)
+ wrote = False
try:
with PlexClient(db) as plex:
plex.add_collection_item(rating_key, str(item_rating_key))
+ wrote = True
col = plex_sync.resync_collection(db, plex, lib, rating_key)
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
- raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
+ raise _write_or_pending_error(wrote, e)
return _collection_card(col)
@@ -171,14 +303,16 @@ def collection_remove_item(
) -> dict:
col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id)
+ wrote = False
try:
with PlexClient(db) as plex:
plex.remove_collection_item(rating_key, str(item_rating_key))
+ wrote = True
col = plex_sync.resync_collection(db, plex, lib, rating_key)
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
- raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
+ raise _write_or_pending_error(wrote, e)
return _collection_card(col)
@@ -197,15 +331,18 @@ def collection_rename(
title = body.title.strip()
if not title:
raise HTTPException(status_code=400, detail="title is required")
+ _reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
lib = db.get(PlexLibrary, col.library_id)
+ wrote = False
try:
with PlexClient(db) as plex:
plex.rename_collection(lib.plex_key, rating_key, title)
+ wrote = True
col = plex_sync.resync_collection(db, plex, lib, rating_key)
db.commit()
except (PlexError, PlexNotConfigured) as e:
db.rollback()
- raise HTTPException(status_code=422, detail=f"Plex write failed: {e}")
+ raise _write_or_pending_error(wrote, e)
return _collection_card(col)
diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py
index 65e2c695..d1a26f00 100644
--- a/backend/app/routes/plex/discover.py
+++ b/backend/app/routes/plex/discover.py
@@ -7,6 +7,11 @@ recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a
every card comes from the same `movie_card`/`show_card` serializers the grid uses (so rails inherit
S1's `art` + S3's rating/summary for free). The rails carry EN metadata (like the other non-grid
card paths); the HU overlay coalesces only in the main grid.
+
+The sidebar metadata filters (genre / rating / year / collection / …) narrow EVERY rail too, so the
+Discover surface behaves like the Browse grid — the same `LibraryFilters` / `meta_filter_conds` the
+grid uses, applied to each rail's movie + show query (a rail may legitimately come back empty, which
+the client hides). Genre-language matches the grid; duration is movie-only.
"""
from fastapi import APIRouter, Depends, Query
@@ -17,8 +22,15 @@ from app.auth import current_user
from app.db import get_db
from app.models import PlexItem, PlexShow, PlexState, User
from app.plex import rollup
-from app.plex.cards import movie_card, show_card
-from app.plex.query import apply_show_state_filter, genres_col
+from app.plex.cards import leaf_card, movie_card, show_card
+from app.plex.query import (
+ LibraryFilters,
+ apply_meta_filters,
+ apply_show_state_filter,
+ genres_col,
+ meta_filter_conds,
+ top_art_keys,
+)
from app.plex.tmdb import resolve_recs
from app.routes.plex._shared import _content_libs, _norm_lang
@@ -26,6 +38,7 @@ router = APIRouter(prefix="/api/plex", tags=["plex"])
RAIL_LIMIT = 20
GENRE_TILES = 12
+SURPRISE_PER_KIND = 3 # Surprise-me: at most this many movies + this many shows (6 total)
def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
@@ -37,6 +50,16 @@ def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]:
)
+def _movie_meta(q, p: dict, lang: str, f: LibraryFilters):
+ """Apply the shared metadata filters + movie-only duration to a movie query (same as the grid)."""
+ q = apply_meta_filters(q, PlexItem, p, lang)
+ if f.duration_min is not None:
+ q = q.where(PlexItem.duration_s >= f.duration_min)
+ if f.duration_max is not None:
+ q = q.where(PlexItem.duration_s <= f.duration_max)
+ return q
+
+
def _movie_cards(db: Session, uid: int, items: list[PlexItem]) -> list[dict]:
sts = rollup.item_state_map(db, uid, [it.id for it in items])
return [movie_card(it, sts.get(it.id)) for it in items]
@@ -48,13 +71,17 @@ def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]:
def _recently_added(
- db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
+ db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
) -> list[dict]:
"""Newest movies + shows, interleaved by `added_at` desc into one rail."""
movies = (
db.execute(
- select(PlexItem)
- .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
+ _movie_meta(
+ select(PlexItem).where(
+ PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)
+ ),
+ p, lang, f,
+ )
.order_by(PlexItem.added_at.desc().nullslast())
.limit(RAIL_LIMIT)
).scalars().all()
@@ -63,8 +90,9 @@ def _recently_added(
)
shows = (
db.execute(
- select(PlexShow)
- .where(PlexShow.library_id.in_(show_ids))
+ apply_meta_filters(
+ select(PlexShow).where(PlexShow.library_id.in_(show_ids)), PlexShow, p, lang
+ )
.order_by(PlexShow.added_at.desc().nullslast())
.limit(RAIL_LIMIT)
).scalars().all()
@@ -91,15 +119,57 @@ def _recently_added(
]
+def _recently_watched(
+ db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
+) -> list[dict]:
+ """Recently watched rail: the titles the user last FULLY watched, newest first. A movie is its own
+ card; a show contributes the specific EPISODE watched (carrying its show title). Movies honour the
+ sidebar filters (like the other rails); episodes carry no filterable metadata, so they don't."""
+ rows: list[tuple[object, dict]] = [] # (watched_at, card)
+ if movie_ids:
+ mq = (
+ select(PlexItem, PlexState)
+ .join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
+ .where(
+ PlexItem.kind == "movie",
+ PlexItem.library_id.in_(movie_ids),
+ PlexState.status == "watched",
+ )
+ )
+ for it, st in db.execute(
+ _movie_meta(mq, p, lang, f)
+ .order_by(PlexState.watched_at.desc().nullslast())
+ .limit(RAIL_LIMIT)
+ ).all():
+ rows.append((st.watched_at, leaf_card(it, st)))
+ if show_ids:
+ eq = (
+ select(PlexItem, PlexState, PlexShow.title, PlexShow.rating_key)
+ .join(PlexState, and_(PlexState.item_id == PlexItem.id, PlexState.user_id == uid))
+ .join(PlexShow, PlexShow.id == PlexItem.show_id)
+ .where(
+ PlexItem.kind == "episode",
+ PlexItem.library_id.in_(show_ids),
+ PlexState.status == "watched",
+ )
+ .order_by(PlexState.watched_at.desc().nullslast())
+ .limit(RAIL_LIMIT)
+ )
+ for ep, st, sh_title, sh_rk in db.execute(eq).all():
+ rows.append((st.watched_at, leaf_card(ep, st, show_title=sh_title, show_rk=sh_rk)))
+ rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True)
+ return [card for _, card in rows[:RAIL_LIMIT]]
+
+
def _continue_watching(
- db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
+ db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
) -> list[dict]:
"""Resume rail: in-progress movies + partially-watched shows, most-recently-progressed first.
A movie's resume time is its own `progress_updated_at`; a show's is the max across its episodes."""
rows: list[tuple[object, dict]] = [] # (resumed_at, card)
if movie_ids:
- mrows = db.execute(
+ mq = (
select(PlexItem, PlexState)
.join(
PlexState,
@@ -116,6 +186,9 @@ def _continue_watching(
PlexState.status.notin_(["watched", "hidden"]),
),
)
+ )
+ mrows = db.execute(
+ _movie_meta(mq, p, lang, f)
.order_by(PlexState.progress_updated_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all()
@@ -127,15 +200,15 @@ def _continue_watching(
# its `resumed_at` = max(progress_updated_at); the in-progress predicate comes from the same shared
# `apply_show_state_filter` the grid uses, so the rail can't drift from the grid's definition.
prog = rollup.agg_subquery(db, uid, include_resumed=True).subquery()
+ sq = apply_show_state_filter(
+ select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
+ prog.c.total,
+ prog.c.watched,
+ prog.c.inprog,
+ "in_progress",
+ ).where(PlexShow.library_id.in_(show_ids))
srows = db.execute(
- apply_show_state_filter(
- select(PlexShow, prog.c.resumed_at).join(prog, prog.c.show_id == PlexShow.id),
- prog.c.total,
- prog.c.watched,
- prog.c.inprog,
- "in_progress",
- )
- .where(PlexShow.library_id.in_(show_ids))
+ apply_meta_filters(sq, PlexShow, p, lang)
.order_by(prog.c.resumed_at.desc().nullslast())
.limit(RAIL_LIMIT)
).all()
@@ -147,17 +220,20 @@ def _continue_watching(
def _genre_tiles(
- db: Session, movie_ids: list[int], show_ids: list[int], lang: str
+ db: Session, movie_ids: list[int], show_ids: list[int], p: dict, lang: str
) -> list[dict]:
"""The most-common genres across the scope, as {value, count} tiles → tap to filter the grid.
- The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid."""
+ The genre vocabulary is language-specific (coalesce(genres_hu, genres) in HU), matching the grid.
+ The active metadata filters narrow the counts too, so the tiles agree with the filtered rails."""
counts: dict[str, int] = {}
for model, ids in ((PlexItem, movie_ids), (PlexShow, show_ids)):
if not ids:
continue
gcol = genres_col(model, lang)
base = select(func.jsonb_array_elements_text(gcol).label("g")).where(
- model.library_id.in_(ids), func.jsonb_typeof(gcol) == "array"
+ model.library_id.in_(ids),
+ func.jsonb_typeof(gcol) == "array",
+ *meta_filter_conds(model, p, lang),
)
if model is PlexItem:
base = base.where(model.kind == "movie")
@@ -167,15 +243,25 @@ def _genre_tiles(
).all():
counts[g] = counts.get(g, 0) + c
top = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:GENRE_TILES]
- return [{"value": g, "count": c} for g, c in top]
+ # A 2×2 poster collage per tile: the top movies in that genre (color hash is the client fallback).
+ art = top_art_keys(
+ db,
+ PlexItem,
+ movie_ids,
+ genres_col(PlexItem, lang),
+ [g for g, _ in top],
+ extra_conds=(PlexItem.kind == "movie", *meta_filter_conds(PlexItem, p, lang)),
+ )
+ return [{"value": g, "count": c, "art": art.get(g, [])} for g, c in top]
def _because_you_watched(
- db: Session, uid: int, movie_ids: list[int], show_ids: list[int]
+ db: Session, uid: int, movie_ids: list[int], show_ids: list[int], p: dict, lang: str, f: LibraryFilters
) -> dict | None:
"""Recommendations anchored on the user's most-recently-watched, TMDB-enriched movie. Only the
recs that resolve to OUR library (by guid) become cards — the rest aren't actionable here. Returns
- None (rail hidden) when there's no enriched watch history or none of its recs are in the library."""
+ None (rail hidden) when there's no enriched watch history or none of its recs are in the library.
+ The metadata filters narrow the resolved recs (not the anchor pick) so the rail matches the grid."""
if not movie_ids:
return None
anchor = db.execute(
@@ -207,15 +293,20 @@ def _because_you_watched(
movies = {
m.rating_key: m
for m in db.execute(
- select(PlexItem).where(
- PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)
+ _movie_meta(
+ select(PlexItem).where(
+ PlexItem.kind == "movie", PlexItem.rating_key.in_(rks)
+ ),
+ p, lang, f,
)
).scalars().all()
}
shows = {
s.rating_key: s
for s in db.execute(
- select(PlexShow).where(PlexShow.rating_key.in_(rks))
+ apply_meta_filters(
+ select(PlexShow).where(PlexShow.rating_key.in_(rks)), PlexShow, p, lang
+ )
).scalars().all()
}
ordered_movies = [movies[rk] for rk in rks if rk in movies]
@@ -238,52 +329,86 @@ def _because_you_watched(
def discover(
scope: str = "both",
lang: str = "en",
+ f: LibraryFilters = Depends(),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
- """The Discover landing payload — every rail in one pass (serve-from-DB). Empty rails come back as
- empty lists (the client hides them); `because_you_watched` is null when there's no anchor."""
+ """The Discover landing payload — every rail in one pass (serve-from-DB), narrowed by the active
+ sidebar filters. Empty rails come back as empty lists (the client hides them); `because_you_watched`
+ is null when there's no anchor."""
lang = _norm_lang(lang)
+ p = f.as_dict()
movie_ids, show_ids = _scope_libs(db, scope)
return {
"scope": scope,
- "continue_watching": _continue_watching(db, user.id, movie_ids, show_ids),
- "recently_added": _recently_added(db, user.id, movie_ids, show_ids),
- "genres": _genre_tiles(db, movie_ids, show_ids, lang),
- "because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids),
+ "continue_watching": _continue_watching(db, user.id, movie_ids, show_ids, p, lang, f),
+ "recently_watched": _recently_watched(db, user.id, movie_ids, show_ids, p, lang, f),
+ "recently_added": _recently_added(db, user.id, movie_ids, show_ids, p, lang, f),
+ "genres": _genre_tiles(db, movie_ids, show_ids, p, lang),
+ "because_you_watched": _because_you_watched(db, user.id, movie_ids, show_ids, p, lang, f),
}
@router.get("/surprise")
def surprise(
scope: str = "both",
- limit: int = Query(default=RAIL_LIMIT, ge=1, le=40),
+ lang: str = "en",
+ limit: int = Query(default=2 * SURPRISE_PER_KIND, ge=1, le=40),
+ f: LibraryFilters = Depends(),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
- """A fresh random sample for the reshuffling Surprise-me rail (a random draw each call). Movies and
- shows are drawn separately then interleaved so a lopsided library doesn't crowd out one type."""
+ """A fresh random sample for the reshuffling Surprise-me rail (a random draw each call), narrowed by
+ the active filters. Only titles the user has NOT seen — a watched movie / a fully-watched show is
+ excluded (the point is to surface something to watch). Capped at SURPRISE_PER_KIND movies +
+ SURPRISE_PER_KIND shows, drawn separately then interleaved so a lopsided library doesn't crowd out
+ one type."""
+ lang = _norm_lang(lang)
+ p = f.as_dict()
movie_ids, show_ids = _scope_libs(db, scope)
- movies = (
- db.execute(
- select(PlexItem)
- .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids))
+ per = min(limit, SURPRISE_PER_KIND)
+ movies = []
+ if movie_ids:
+ # Exclude movies this user has watched.
+ watched_movie = (
+ select(PlexState.id)
+ .where(
+ PlexState.item_id == PlexItem.id,
+ PlexState.user_id == user.id,
+ PlexState.status == "watched",
+ )
+ .exists()
+ )
+ movies = db.execute(
+ _movie_meta(
+ select(PlexItem).where(
+ PlexItem.kind == "movie",
+ PlexItem.library_id.in_(movie_ids),
+ ~watched_movie,
+ ),
+ p, lang, f,
+ )
.order_by(func.random())
- .limit(limit)
+ .limit(per)
).scalars().all()
- if movie_ids
- else []
- )
- shows = (
- db.execute(
- select(PlexShow)
- .where(PlexShow.library_id.in_(show_ids))
+ shows = []
+ if show_ids:
+ # Exclude fully-watched shows (LEFT JOIN so a never-started show — no rollup row — still counts
+ # as unseen). `total IS NULL / == 0 / watched < total` == "not fully watched".
+ prog = rollup.agg_subquery(db, user.id).subquery()
+ not_fully_watched = or_(
+ prog.c.total.is_(None), prog.c.total == 0, prog.c.watched < prog.c.total
+ )
+ shows = db.execute(
+ apply_meta_filters(
+ select(PlexShow)
+ .outerjoin(prog, prog.c.show_id == PlexShow.id)
+ .where(PlexShow.library_id.in_(show_ids), not_fully_watched),
+ PlexShow, p, lang,
+ )
.order_by(func.random())
- .limit(limit)
+ .limit(per)
).scalars().all()
- if show_ids
- else []
- )
mcards = _movie_cards(db, user.id, movies)
scards = _show_cards(db, user.id, shows)
# Interleave movie/show cards, then trim to `limit`.
diff --git a/backend/app/routes/saved_views.py b/backend/app/routes/saved_views.py
index f9bb8f66..2efe166a 100644
--- a/backend/app/routes/saved_views.py
+++ b/backend/app/routes/saved_views.py
@@ -9,11 +9,19 @@ from app.db import get_db
from app.models import SavedView, User
from app.routes._common import owned_or_404
-# Saved "smart views": per-user named snapshots of the feed's FeedFilters. Gated behind
-# require_human so the shared demo account can't pollute a communal list (mirrors how other
-# personal-state writes are scoped).
+# Saved "smart views": per-user named snapshots of a module's filters (feed or plex, keyed by
+# `source`). Gated behind require_human so the shared demo account can't pollute a communal list
+# (mirrors how other personal-state writes are scoped).
router = APIRouter(prefix="/api/saved-views", tags=["saved-views"])
+# The modules that own saved views. A view is scoped to exactly one; the default is "feed" so a caller
+# (or an older client) that omits `source` keeps the original feed behaviour.
+_SOURCES = {"feed", "plex"}
+
+
+def _norm_source(source: str) -> str:
+ return source if source in _SOURCES else "feed"
+
def _serialize(v: SavedView) -> dict:
return {
@@ -27,12 +35,15 @@ def _serialize(v: SavedView) -> dict:
@router.get("")
def list_views(
- user: User = Depends(require_human), db: Session = Depends(get_db)
+ source: str = "feed",
+ user: User = Depends(require_human),
+ db: Session = Depends(get_db),
) -> list[dict]:
+ source = _norm_source(source)
rows = (
db.execute(
select(SavedView)
- .where(SavedView.user_id == user.id)
+ .where(SavedView.user_id == user.id, SavedView.source == source)
.order_by(SavedView.position, SavedView.id)
)
.scalars()
@@ -44,6 +55,7 @@ def list_views(
class CreateViewIn(BaseModel):
name: str
filters: dict
+ source: str = "feed"
@router.post("")
@@ -55,13 +67,19 @@ def create_view(
name = body.name.strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
+ source = _norm_source(body.source)
+ # Position is per (user, source), so each module's views number 0..N independently.
maxpos = db.execute(
select(func.coalesce(func.max(SavedView.position), -1)).where(
- SavedView.user_id == user.id
+ SavedView.user_id == user.id, SavedView.source == source
)
).scalar_one()
view = SavedView(
- user_id=user.id, name=name[:80], filters=body.filters, position=maxpos + 1
+ user_id=user.id,
+ source=source,
+ name=name[:80],
+ filters=body.filters,
+ position=maxpos + 1,
)
db.add(view)
try:
@@ -107,10 +125,14 @@ def update_view(
if "is_default" in sent:
make_default = bool(body.is_default)
if make_default:
- # At most one default per user: clear the flag on the others first.
+ # At most one default per (user, source): clear the flag on this module's others first.
db.execute(
update(SavedView)
- .where(SavedView.user_id == user.id, SavedView.id != view.id)
+ .where(
+ SavedView.user_id == user.id,
+ SavedView.source == view.source,
+ SavedView.id != view.id,
+ )
.values(is_default=False)
)
view.is_default = make_default
diff --git a/backend/tests/test_plex_collections_filter.py b/backend/tests/test_plex_collections_filter.py
new file mode 100644
index 00000000..11a0891d
--- /dev/null
+++ b/backend/tests/test_plex_collections_filter.py
@@ -0,0 +1,301 @@
+"""Piece 1a: the Plex collection filter is multi-select with any/all combine (a title can belong to
+several collections). `collections=A,B` = ANY (OR, default); `&collection_mode=all` = ALL (AND).
+DB lane (`db` fixture) — skipped without Postgres.
+"""
+from datetime import datetime, timezone
+
+from fastapi.testclient import TestClient
+from sqlalchemy import select
+
+import app.state as state
+from app.auth import admin_user, current_user
+from app.db import get_db
+from app.main import app
+from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, User
+from app.plex.client import PlexError
+from app.routes.plex import collections as coll_routes
+
+
+def _seed(db):
+ """Three movies: one in collection A, one in B, one in both — so any/all are distinguishable."""
+ lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
+ db.add(lib)
+ db.flush()
+ common = dict(
+ library_id=lib.id,
+ kind="movie",
+ people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
+ )
+ db.add_all([
+ PlexItem(rating_key="mA", title="Only A", collection_keys=["A"],
+ added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common),
+ PlexItem(rating_key="mB", title="Only B", collection_keys=["B"],
+ added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common),
+ PlexItem(rating_key="mAB", title="A and B", collection_keys=["A", "B"],
+ added_at=datetime(2024, 1, 3, tzinfo=timezone.utc), **common),
+ ])
+ user = User(email="plexcoll@example.com")
+ db.add(user)
+ db.commit()
+ return user
+
+
+def _get(db, user, monkeypatch, path):
+ monkeypatch.setattr(state, "_configured_cache", True)
+ app.dependency_overrides[get_db] = lambda: db
+ app.dependency_overrides[current_user] = lambda: user
+ try:
+ return TestClient(app).get(path)
+ finally:
+ app.dependency_overrides.clear()
+
+
+def _titles(resp):
+ return {i["title"] for i in resp.json()["items"]}
+
+
+def test_single_collection_filters_to_its_members(db, monkeypatch):
+ user = _seed(db)
+ resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A")
+ assert _titles(resp) == {"Only A", "A and B"}
+
+
+def test_multiple_collections_any_is_the_union(db, monkeypatch):
+ user = _seed(db)
+ resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&collections=A,B")
+ assert _titles(resp) == {"Only A", "Only B", "A and B"}
+
+
+def test_multiple_collections_all_is_the_intersection(db, monkeypatch):
+ user = _seed(db)
+ resp = _get(
+ db, user, monkeypatch,
+ "/api/plex/library?scope=movie&collections=A,B&collection_mode=all",
+ )
+ assert _titles(resp) == {"A and B"}
+
+
+def test_collection_cards_carry_montage_art_and_total_runtime(db, monkeypatch):
+ # The browse-tile montage (top films, best-rated first) + the collection's total runtime.
+ lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
+ db.add(lib)
+ db.flush()
+ db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", child_count=2))
+ common = dict(library_id=lib.id, kind="movie", collection_keys=["A"])
+ db.add_all([
+ PlexItem(rating_key="m1", title="M1", duration_s=3600, rating=8.0,
+ added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), **common),
+ PlexItem(rating_key="m2", title="M2", duration_s=1800, rating=9.0,
+ added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), **common),
+ ])
+ user = User(email="collart@example.com")
+ db.add(user)
+ db.commit()
+ card = next(c for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
+ if c["id"] == "A")
+ assert card["art"] == ["m2", "m1"] # best-rated first (m2 9.0 > m1 8.0)
+ assert card["total_runtime_s"] == 5400 # 3600 + 1800
+
+
+def test_create_collection_rejects_duplicate_name(db, monkeypatch):
+ # Same-named collections conflate in Plex, so a create with an existing name (case-insensitive) is
+ # rejected 409 — BEFORE any Plex call (the guard runs first, so the DB lane can assert it).
+ monkeypatch.setattr(state, "_configured_cache", True)
+ lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
+ db.add(lib)
+ db.flush()
+ db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Marvel"))
+ db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1"))
+ user = User(email="dup@example.com")
+ db.add(user)
+ db.commit()
+ app.dependency_overrides[get_db] = lambda: db
+ app.dependency_overrides[current_user] = lambda: user
+ app.dependency_overrides[admin_user] = lambda: user
+ try:
+ r = TestClient(app).post(
+ "/api/plex/collections",
+ json={"library": "1", "title": "marvel", "item_rating_key": "m1"},
+ )
+ assert r.status_code == 409, r.text
+ finally:
+ app.dependency_overrides.clear()
+
+
+def test_collection_detail_hero_and_ordered_members(db, monkeypatch):
+ # The Collection info page: hero aggregates (year range, most-restrictive cert, total runtime) +
+ # members ordered by release year then title.
+ lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
+ db.add(lib)
+ db.flush()
+ db.add(PlexCollection(rating_key="A", library_id=lib.id, title="Coll A", summary="S", child_count=2))
+ common = dict(library_id=lib.id, kind="movie", collection_keys=["A"])
+ db.add_all([
+ PlexItem(rating_key="later", title="Later", year=1996, duration_s=3600, content_rating="PG", **common),
+ PlexItem(rating_key="early", title="Early", year=1992, duration_s=1800, content_rating="G", **common),
+ ])
+ user = User(email="cd@example.com")
+ db.add(user)
+ db.commit()
+ d = _get(db, user, monkeypatch, "/api/plex/collections/A").json()
+ assert d["title"] == "Coll A" and d["summary"] == "S"
+ assert d["year_min"] == 1992 and d["year_max"] == 1996
+ assert d["content_rating"] == "PG" # most restrictive of {G, PG}
+ assert d["total_runtime_s"] == 5400
+ assert [i["id"] for i in d["items"]] == ["early", "later"] # release-year order
+
+
+# --- Write-through collection mutations (create/add/remove/rename write to Plex, then resync the local
+# mirror). We stub PlexClient so the DB lane can drive the happy path, the 422-on-mutation-failure
+# rollback, and the 502-on-resync-failure split (a successful write whose follow-up refresh read fails
+# must NOT be reported as a write failure). ------------------------------------------------------------
+class _CollStub:
+ """A PlexClient stand-in recording calls; `fail_on` makes one method raise PlexError."""
+
+ def __init__(self, fail_on: str | None = None):
+ self.fail_on = fail_on
+ self.calls: list[str] = []
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *a):
+ return False
+
+ def _maybe(self, name: str):
+ self.calls.append(name)
+ if self.fail_on == name:
+ raise PlexError("boom")
+
+ def create_collection(self, section_key, title, item_rating_key):
+ self._maybe("create_collection")
+ return {"ratingKey": "NEW"}
+
+ def add_collection_item(self, rk, item):
+ self._maybe("add_collection_item")
+
+ def remove_collection_item(self, rk, item):
+ self._maybe("remove_collection_item")
+
+ def rename_collection(self, section_key, rk, title):
+ self._maybe("rename_collection")
+
+ def delete_collection(self, rk):
+ self._maybe("delete_collection")
+
+
+def _resync_ok(db, plex, lib, rk):
+ """Stand in for the post-write refresh: return the existing local row (no live Plex read)."""
+ return db.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
+
+
+def _seed_editable(db):
+ lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
+ db.add(lib)
+ db.flush()
+ # Plain, admin-editable collection (title isn't a provider word, not smart → source "collection").
+ db.add(PlexCollection(rating_key="A", library_id=lib.id, title="My Coll", editable=True,
+ child_count=1))
+ db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1",
+ collection_keys=["A"]))
+ user = User(email="mut@example.com")
+ db.add(user)
+ db.commit()
+ return lib, user
+
+
+def _admin_client(db, user, monkeypatch, stub, resync=_resync_ok):
+ monkeypatch.setattr(state, "_configured_cache", True)
+ monkeypatch.setattr(coll_routes, "PlexClient", lambda db: stub)
+ monkeypatch.setattr(coll_routes.plex_sync, "resync_collection", resync)
+ app.dependency_overrides[get_db] = lambda: db
+ app.dependency_overrides[current_user] = lambda: user
+ app.dependency_overrides[admin_user] = lambda: user
+ return TestClient(app)
+
+
+def test_rename_writes_through_to_plex(db, monkeypatch):
+ _lib, user = _seed_editable(db)
+ stub = _CollStub()
+ client = _admin_client(db, user, monkeypatch, stub)
+ try:
+ r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
+ assert r.status_code == 200, r.text
+ assert "rename_collection" in stub.calls
+ finally:
+ app.dependency_overrides.clear()
+
+
+def test_remove_item_writes_through_to_plex(db, monkeypatch):
+ _lib, user = _seed_editable(db)
+ stub = _CollStub()
+ client = _admin_client(db, user, monkeypatch, stub)
+ try:
+ r = client.delete("/api/plex/collections/A/items/m1")
+ assert r.status_code == 200, r.text
+ assert "remove_collection_item" in stub.calls
+ finally:
+ app.dependency_overrides.clear()
+
+
+def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
+ # The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
+ # is never reached (stub.calls holds only the mutating call).
+ _lib, user = _seed_editable(db)
+ stub = _CollStub(fail_on="rename_collection")
+ client = _admin_client(db, user, monkeypatch, stub)
+ try:
+ r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
+ assert r.status_code == 422, r.text
+ assert "write failed" in r.json()["detail"].lower()
+ finally:
+ app.dependency_overrides.clear()
+
+
+def test_resync_failure_after_successful_write_is_422_and_rolls_back(db, monkeypatch):
+ # The mutation landed on Plex; only the follow-up refresh READ failed. We must (a) NOT claim a
+ # write failure and NOT use a 5xx (which the frontend treats as offline) → 422 with a "reconcile
+ # on the next sync" detail, and (b) roll back any partial local upsert the resync had flushed.
+ _lib, user = _seed_editable(db)
+ stub = _CollStub()
+
+ def _resync_boom(db_, plex, lib_, rk):
+ # Simulate resync having flushed a partial local change before its Plex read blew up — the
+ # route's rollback must undo it (this is what makes the rollback assertion non-vacuous).
+ row = db_.execute(select(PlexCollection).where(PlexCollection.rating_key == rk)).scalars().first()
+ row.title = "PARTIAL_SHOULD_ROLL_BACK"
+ db_.flush()
+ raise PlexError("read failed")
+
+ client = _admin_client(db, user, monkeypatch, stub, resync=_resync_boom)
+ try:
+ r = client.patch("/api/plex/collections/A", json={"title": "Renamed"})
+ assert r.status_code == 422, r.text
+ assert "reconcile" in r.json()["detail"].lower()
+ assert "rename_collection" in stub.calls # the write DID happen
+ finally:
+ app.dependency_overrides.clear()
+ row = db.execute(select(PlexCollection).where(PlexCollection.rating_key == "A")).scalars().first()
+ assert row.title == "My Coll" # the partial resync upsert was rolled back
+
+
+def test_show_collection_gets_montage_art_from_shows(db, monkeypatch):
+ # A show-library collection must get its montage from PlexShow members (else it renders blank) — the
+ # A3 fix that unions movie + show art. Runtime stays 0 (shows have no single duration).
+ lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
+ db.add(lib)
+ db.flush()
+ db.add(PlexCollection(rating_key="S", library_id=lib.id, title="Show Coll", child_count=2))
+ db.add_all([
+ PlexShow(rating_key="s1", library_id=lib.id, title="S1", collection_keys=["S"], rating=7.0,
+ added_at=datetime(2024, 1, 1, tzinfo=timezone.utc)),
+ PlexShow(rating_key="s2", library_id=lib.id, title="S2", collection_keys=["S"], rating=9.0,
+ added_at=datetime(2024, 1, 2, tzinfo=timezone.utc)),
+ ])
+ user = User(email="showcoll@example.com")
+ db.add(user)
+ db.commit()
+ card = next(c for c in _get(db, user, monkeypatch, "/api/plex/collections").json()["collections"]
+ if c["id"] == "S")
+ assert card["art"] == ["s2", "s1"] # show members, best-rated first
+ assert card["total_runtime_s"] == 0
diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py
index bc77d2cd..0643f588 100644
--- a/backend/tests/test_plex_discover.py
+++ b/backend/tests/test_plex_discover.py
@@ -6,6 +6,7 @@ without Postgres.
from datetime import datetime, timedelta, timezone
from fastapi.testclient import TestClient
+from sqlalchemy import select
import app.state as state
from app.auth import current_user
@@ -103,6 +104,16 @@ def test_discover_shape_and_recently_added_order(db, monkeypatch):
assert genres.get("Drama") == 2 and genres.get("Comedy") == 1
+def test_genre_tiles_carry_poster_art(db, monkeypatch):
+ # Each genre tile carries up to 4 poster rating_keys for the 2×2 collage (top_art_keys): the top
+ # movies in that genre, best-rated then newest. No ratings here → newest-first; shows are excluded.
+ user = _seed(db)
+ d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
+ art = {g["value"]: g["art"] for g in d["genres"]}
+ assert art["Drama"] == ["m1", "m3"] # both Drama movies, m1 (newer) first
+ assert art["Comedy"] == ["m2"]
+
+
def test_continue_watching_movies_and_shows(db, monkeypatch):
user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()
@@ -194,9 +205,63 @@ def test_surprise_scope_and_bound(db, monkeypatch):
assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows
+def test_recently_watched_movies_and_episodes(db, monkeypatch):
+ # The rail lists last-fully-watched titles newest-first: a watched MOVIE as its own card, a show as
+ # the specific watched EPISODE (with its show title). _seed watches m1 (movie); add a watched episode.
+ user = _seed(db)
+ ep2 = db.execute(
+ select(PlexItem).where(PlexItem.rating_key == "sh1e2")
+ ).scalars().one()
+ db.add(PlexState(user_id=user.id, item_id=ep2.id, status="watched", watched_at=NOW))
+ db.commit()
+ rw = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json()["recently_watched"]
+ by_id = {c["id"]: c for c in rw}
+ assert "m1" in by_id and by_id["m1"]["type"] == "movie"
+ assert "sh1e2" in by_id and by_id["sh1e2"]["type"] == "episode"
+ assert by_id["sh1e2"]["show_title"] == "A Show" # episode carries its show's title
+ # ep2 was watched at NOW (newest) → it leads over m1 (watched earlier in _seed... same NOW ok).
+ assert set(_ids(rw)) == {"m1", "sh1e2"}
+
+
+def test_surprise_excludes_watched_and_caps_per_kind(db, monkeypatch):
+ # Surprise surfaces only UNSEEN titles: the watched movie (m1) is out; the unwatched (m2), the
+ # in-progress movie (m3) and the partially-watched show (sh1) are eligible. Capped at 3 per kind.
+ user = _seed(db)
+ items = _get(db, user, monkeypatch, "/api/plex/surprise?scope=both").json()["items"]
+ ids = _ids(items)
+ assert "m1" not in ids # watched → excluded
+ assert set(ids) <= {"m2", "m3", "sh1"} # only unseen titles
+ assert len([c for c in items if c["type"] == "movie"]) <= 3
+ assert len([c for c in items if c["type"] == "show"]) <= 3
+
+
def test_discover_scope_movie_excludes_shows(db, monkeypatch):
user = _seed(db)
d = _get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()
assert all(c["type"] == "movie" for c in d["recently_added"])
# The show drops out of continue watching under the movie scope.
assert _ids(d["continue_watching"]) == ["m3"]
+
+
+def test_filters_narrow_every_discover_rail(db, monkeypatch):
+ # The sidebar filters constrain the Discover rails just like the Browse grid (ask 3). A genre=Drama
+ # filter keeps only the Drama titles across every rail; the genre-less show and the Comedy rec drop.
+ user = _seed(db)
+ d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both&genres=Drama").json()
+ # Recently-added: m1 + m3 (Drama) survive; Comedy m2 and the genre-less show sh1 are filtered out.
+ assert set(_ids(d["recently_added"])) == {"m1", "m3"}
+ # Continue-watching: only the in-progress Drama movie remains (the show has no genres → excluded).
+ assert _ids(d["continue_watching"]) == ["m3"]
+ # Genre tiles reflect the filtered set — only Drama, count 2 (m1 + m3).
+ assert {g["value"]: g["count"] for g in d["genres"]} == {"Drama": 2}
+ # Because-you-watched: the anchor m1 is watched, but its only rec (m2) is Comedy → filtered → no rail.
+ assert d["because_you_watched"] is None
+
+
+def test_filters_narrow_surprise(db, monkeypatch):
+ user = _seed(db)
+ items = _get(
+ db, user, monkeypatch, "/api/plex/surprise?scope=movie&genres=Comedy"
+ ).json()["items"]
+ # Only the one Comedy movie qualifies under the filter.
+ assert _ids(items) == ["m2"]
diff --git a/backend/tests/test_saved_views_source.py b/backend/tests/test_saved_views_source.py
new file mode 100644
index 00000000..0c3bad19
--- /dev/null
+++ b/backend/tests/test_saved_views_source.py
@@ -0,0 +1,75 @@
+"""ask 4: saved views are per-module (`source`). Feed and Plex views are separate lists, may share a
+name, and each has its own single default. DB lane (`db` fixture) — skipped without Postgres.
+"""
+from fastapi.testclient import TestClient
+
+import app.state as state
+from app.auth import current_user
+from app.db import get_db
+from app.main import app
+from app.models import User
+
+
+def _client(db, user, monkeypatch):
+ monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app
+ app.dependency_overrides[get_db] = lambda: db
+ app.dependency_overrides[current_user] = lambda: user # require_human resolves through this
+ return TestClient(app)
+
+
+def _user(db):
+ u = User(email="views@example.com")
+ db.add(u)
+ db.commit()
+ return u
+
+
+def test_views_are_scoped_by_source(db, monkeypatch):
+ user = _user(db)
+ try:
+ c = _client(db, user, monkeypatch)
+ # Same NAME under two sources is allowed (unique is per user+source now).
+ f = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"a": 1}, "source": "feed"})
+ p = c.post("/api/saved-views", json={"name": "Fresh", "filters": {"b": 2}, "source": "plex"})
+ assert f.status_code == 200 and p.status_code == 200, (f.text, p.text)
+
+ feed = c.get("/api/saved-views?source=feed").json()
+ plex = c.get("/api/saved-views?source=plex").json()
+ assert [v["name"] for v in feed] == ["Fresh"]
+ assert [v["name"] for v in plex] == ["Fresh"]
+ assert feed[0]["filters"] == {"a": 1} and plex[0]["filters"] == {"b": 2}
+ # A source-less GET keeps the old feed-only behaviour.
+ assert [v["id"] for v in c.get("/api/saved-views").json()] == [v["id"] for v in feed]
+ finally:
+ app.dependency_overrides.clear()
+
+
+def test_default_is_per_source(db, monkeypatch):
+ user = _user(db)
+ try:
+ c = _client(db, user, monkeypatch)
+ fid = c.post(
+ "/api/saved-views", json={"name": "F", "filters": {}, "source": "feed"}
+ ).json()["id"]
+ pid = c.post(
+ "/api/saved-views", json={"name": "P", "filters": {}, "source": "plex"}
+ ).json()["id"]
+ c.patch(f"/api/saved-views/{fid}", json={"is_default": True})
+ c.patch(f"/api/saved-views/{pid}", json={"is_default": True})
+ # Making the Plex view default must NOT clear the feed view's default (different source).
+ assert c.get("/api/saved-views?source=feed").json()[0]["is_default"] is True
+ assert c.get("/api/saved-views?source=plex").json()[0]["is_default"] is True
+ finally:
+ app.dependency_overrides.clear()
+
+
+def test_duplicate_name_within_a_source_conflicts(db, monkeypatch):
+ user = _user(db)
+ try:
+ c = _client(db, user, monkeypatch)
+ a = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
+ b = c.post("/api/saved-views", json={"name": "Dup", "filters": {}, "source": "plex"})
+ assert a.status_code == 200
+ assert b.status_code == 409 # same (user, source, name)
+ finally:
+ app.dependency_overrides.clear()
diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx
index 4325b3ac..9cf34c78 100644
--- a/frontend/src/components/Combobox.tsx
+++ b/frontend/src/components/Combobox.tsx
@@ -220,7 +220,11 @@ export default function Combobox({
? (placeholder ?? t("common.search", { defaultValue: "Search" }))
: ""
}
- className="min-w-16 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60"
+ // min-w-8 (not -16): flex line-packing sizes the input at its MIN-width, so a wide 64px floor
+ // made a single chip + the reserved input + the chevron overflow a narrow rail field — wrapping
+ // the chevron to a second line while the (empty) input filled the first. A small floor keeps the
+ // chip + input + chevron on one line; flex-1 still grows the input to fill whatever room is left.
+ className="min-w-8 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted/60"
/>
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index 7618accc..de2f13aa 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -5,6 +5,7 @@ import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
+import PlexViewToggle from "./PlexViewToggle";
import FeedScopeToggle from "./FeedScopeToggle";
import SyncStatus from "./SyncStatus";
import { useNavigation } from "./NavigationProvider";
@@ -103,8 +104,10 @@ const Header = forwardRef<
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
{page === "feed" && }
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
- the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */}
+ the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. The
+ Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
{page === "plex" && me.plex_enabled && }
+ {page === "plex" && me.plex_enabled && }
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
pills rather than pinned to the far right: on a wide window that just strands it. Only on
the two modules it says anything about: the feed those subscriptions fill, and the manager
diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx
index 802b8a1d..f3a5e7f7 100644
--- a/frontend/src/components/NavigationProvider.tsx
+++ b/frontend/src/components/NavigationProvider.tsx
@@ -14,6 +14,7 @@ import { pageTitleKey } from "../lib/pageMeta";
import { setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
import { setNavigator } from "../lib/nav";
+import { nextNavId, notifyNav } from "../lib/history";
import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
@@ -128,10 +129,11 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// (chan/v/pl) — a page switch resets to that module's root. Feed filters (owned by
// FeedFiltersProvider) are left intact.
window.history.pushState(
- { sfPage: next },
+ { sfPage: next, sfScroll: nextNavId() },
"",
urlWithParams({ page: next === "feed" ? null : next, chan: null, v: null, pl: null }),
);
+ notifyNav();
},
[markDirty],
);
diff --git a/frontend/src/components/PageScroller.tsx b/frontend/src/components/PageScroller.tsx
index d73fcc78..7733f3da 100644
--- a/frontend/src/components/PageScroller.tsx
+++ b/frontend/src/components/PageScroller.tsx
@@ -1,10 +1,25 @@
import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
import { useScrollFade } from "../lib/useScrollFade";
+import { ensureNavId, peekNavId, subscribeNav } from "../lib/history";
-// Per-view saved scroll offsets. Module-level so a view's position survives its scroller
-// unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
-// the same view returns. Keyed by the shell's `scrollKey`.
+// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
+// exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens
+// at the top; the scrollKey ties it to the logical content, so a new feed filter still starts at the
+// top WITHIN the same entry. Module-level so a view's position survives its scroller unmounting
+// (opening a channel page swaps in a different shell/scroller) and is restored when it returns.
const savedScroll = new Map();
+// The key embeds an ever-increasing history-entry id, so without a bound this map would grow one
+// entry per scrolled navigation for the life of the session. Cap it with a small LRU (Map preserves
+// insertion order): re-insert on write to mark most-recently-used, evict the oldest past the cap.
+const SCROLL_MEMORY_CAP = 200;
+function rememberScroll(key: string, top: number): void {
+ savedScroll.delete(key);
+ savedScroll.set(key, top);
+ if (savedScroll.size > SCROLL_MEMORY_CAP) {
+ const oldest = savedScroll.keys().next().value;
+ if (oldest !== undefined) savedScroll.delete(oldest);
+ }
+}
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
@@ -85,23 +100,50 @@ export default function PageScroller({
[fadeRef],
);
+ // Live scrollKey for the event/nav-bus callbacks (which are bound once, not per scrollKey).
+ const scrollKeyRef = useRef(scrollKey);
+ scrollKeyRef.current = scrollKey;
+ const lastComposite = useRef(null);
+ const cancelRef = useRef<() => void>(() => {});
+
+ // Restore (or reset to top) whenever the (history entry + content) identity changes — a page/sub-view
+ // navigation, a Back/Forward, or a feed filter change. Idempotent: a no-op when the composite is
+ // unchanged, so it's safe to call from several triggers.
+ const resolve = useCallback(() => {
+ const el = elRef.current;
+ if (!el || scrollKeyRef.current == null) return;
+ const key = `${ensureNavId()}:${scrollKeyRef.current}`;
+ if (key === lastComposite.current) return;
+ lastComposite.current = key;
+ cancelRef.current();
+ cancelRef.current = restoreScroll(el, savedScroll.get(key) ?? 0);
+ }, []);
+
+ // Trigger 1: mount + a content-key change (page switch, feed filter) — both re-render this component.
+ useLayoutEffect(() => {
+ resolve();
+ }, [scrollKey, resolve]);
+
+ // Trigger 2: Back/Forward (popstate) and a Plex sub-view open (nav bus) — neither necessarily
+ // re-renders this component. Also records the position continuously (not at unmount): the scroller's
+ // scrollTop is unreliable by the time an unmount cleanup runs, so a save-on-scroll survives teardown.
useLayoutEffect(() => {
const el = elRef.current;
- if (!el || scrollKey == null) return;
- const key = scrollKey;
- const want = savedScroll.get(key) ?? 0;
- const cancelRestore = restoreScroll(el, want);
- // Record position continuously (not at unmount): the scroller's scrollTop is unreliable by the
- // time an unmount cleanup runs, and a save-on-scroll survives whatever tears down last.
+ if (!el) return;
const onScroll = () => {
- savedScroll.set(key, el.scrollTop);
+ if (scrollKeyRef.current == null) return;
+ rememberScroll(`${peekNavId()}:${scrollKeyRef.current}`, el.scrollTop);
};
el.addEventListener("scroll", onScroll, { passive: true });
+ window.addEventListener("popstate", resolve);
+ const unsubNav = subscribeNav(resolve);
return () => {
- cancelRestore();
+ cancelRef.current();
el.removeEventListener("scroll", onScroll);
+ window.removeEventListener("popstate", resolve);
+ unsubNav();
};
- }, [scrollKey]);
+ }, [resolve]);
return (
(activeViewId ?? views[0]?.id ?? null);
const selected = views.find((v) => v.id === selectedId) ?? null;
const [draftName, setDraftName] = useState("");
- // The selected view's captured filters, as the same human labels the toolbar chip row shows.
- const captured = useActiveFilters(
- selected ? asFilters(selected.filters) : ({ tags: [], channelIds: [] } as never),
- () => {},
+ // The selected view's captured filters, as the same human labels its module's chip row shows. The
+ // feed hook runs unconditionally (fed empty filters off-feed); Plex uses the pure builder (no-op
+ // removes — this is display-only). A plex view captures its rail filters, not the watch-state.
+ const noop = () => {};
+ const feedCaptured = useActiveFilters(
+ source === "feed" && selected
+ ? asFilters(selected.filters as unknown as FeedFilters)
+ : ({ tags: [], channelIds: [] } as never),
+ noop,
);
+ const captured =
+ source === "plex" && selected
+ ? buildPlexActiveFilters(coercePlexFilters(selected.filters), "all", noop, noop, t)
+ : feedCaptured;
async function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx
index f49eeeac..93da008d 100644
--- a/frontend/src/components/PlexBrowse.tsx
+++ b/frontend/src/components/PlexBrowse.tsx
@@ -25,20 +25,24 @@ import { useHistorySubview } from "../lib/history";
import { PLEX_SUB_URL, type Sub } from "./plexSubUrl";
import { toggleWatchState } from "../lib/plexWatch";
import SortControl from "./SortControl";
-import SegmentedToggle from "./SegmentedToggle";
+import FilterSelect from "./FilterSelect";
import PlexDiscover from "./PlexDiscover";
+import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists";
import { PageToolbar } from "./PageShell";
import ActiveFilterChips from "./ActiveFilterChips";
+import SelectedViewChip from "./SelectedViewChip";
import ItemCount from "./ItemCount";
import { StateMessage } from "./QueryState";
import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters";
import { usePlex, usePlexActions } from "./PlexProvider";
+import { usePublishPlexView } from "./PlexViewBridge";
import { EpisodeCard, PlexPosterCard } from "./plexCards";
import { PlexInfoView, PlexPersonView, PlexSeasonView, PlexShowView } from "./plexViews";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
+const PlexCollectionView = lazy(() => import("./PlexCollectionView"));
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
@@ -48,6 +52,10 @@ const PlexPlaylistView = lazy(() => import("./PlexPlaylistView"));
const PAGE = 40;
+// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
+// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
+const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
+
export default function PlexBrowse() {
const { t } = useTranslation();
const qc = useQueryClient();
@@ -55,7 +63,7 @@ export default function PlexBrowse() {
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
// shared `playlistOpen` slot, which we consume into a history subview then clear.
const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex();
- const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
+ const { setScope, setShow, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions();
const dq = useDebounced(q.trim(), 350);
// Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so
// a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch.
@@ -77,6 +85,16 @@ export default function PlexBrowse() {
// or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns.
const sub = useHistorySubview({ kind: "discover" }, PLEX_SUB_URL);
+ // Publish the top-level view (discover vs grid) + opener to the header's Discover/Browse toggle via
+ // the bridge; clear it on unmount so the toggle disappears when you leave the plex page. Keyed on the
+ // view KIND (open is behaviourally stable — it takes the next view as an arg, closing over nothing).
+ const publishView = usePublishPlexView();
+ useLayoutEffect(() => {
+ publishView({ kind: sub.view.kind, open: sub.open });
+ return () => publishView(null);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sub.view.kind]);
+
// Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then
// clear the signal so it doesn't re-open.
useEffect(() => {
@@ -100,14 +118,18 @@ export default function PlexBrowse() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jumpTo]);
- // Discover → grid on a browse intent: when a header search or a filter FIRST becomes active while on
- // the Discover landing, switch to the flat grid (which consumes q/filters). Only on the off→on edge,
- // so a pre-existing (persisted) filter doesn't yank a returning user off Discover on mount, and a
- // Back to Discover with the search still in the box doesn't immediately re-flip.
- const browseIntent = !!dq || plexFilterCount(filters) > 0;
+ // Discover → grid on a SEARCH: a header search has no rail on Discover, so entering one FIRST while on
+ // the Discover landing switches to the flat grid (which consumes q). Only on the off→on edge, so a Back
+ // to Discover with the term still in the box doesn't immediately re-flip. Filters are NOT a flip
+ // trigger anymore — Discover honours the active filters itself (its rails narrow like the grid), so
+ // adding a filter leaves you on Discover with narrowed rails instead of yanking you to the grid.
+ const browseIntent = !!dq;
const prevIntent = useRef(browseIntent);
useEffect(() => {
- if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) {
+ // A search targets titles, so jump to the title grid from any non-title landing (Discover or the
+ // playlists/collections browse surfaces) when a search first appears.
+ const onListing = ["discover", "playlists", "collections"].includes(sub.view.kind);
+ if (onListing && browseIntent && !prevIntent.current) {
sub.open({ kind: "grid" });
}
prevIntent.current = browseIntent;
@@ -142,7 +164,19 @@ export default function PlexBrowse() {
const tag = (el?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable)
return;
- if (["grid", "show", "season", "info", "person", "playlist"].includes(sub.view.kind)) {
+ if (
+ [
+ "grid",
+ "playlists",
+ "collections",
+ "show",
+ "season",
+ "info",
+ "person",
+ "playlist",
+ "collection",
+ ].includes(sub.view.kind)
+ ) {
e.preventDefault();
sub.back();
}
@@ -267,10 +301,15 @@ export default function PlexBrowse() {
// (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;
+ const isPlainObject = (x: unknown): x is Record =>
+ typeof x === "object" && x !== null && !Array.isArray(x);
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 if (isPlainObject(v) && isPlainObject(cur)) {
+ // Merge label maps (collectionTitles / playlistTitles) so adding one keeps the others' titles.
+ merged[k] = { ...cur, ...v };
} else {
merged[k] = v;
}
@@ -304,6 +343,20 @@ export default function PlexBrowse() {
);
}
+ if (sub.view.kind === "collection") {
+ const cid = sub.view.id;
+ return (
+ {t("plex.loading")}
}>
+ sub.open({ kind: "player", id: rk, queue })}
+ onOpenCard={onCard}
+ onInfoCard={onInfo}
+ />
+
+ );
+ }
if (sub.view.kind === "info") {
const infoId = sub.view.id;
return (
@@ -355,24 +408,23 @@ export default function PlexBrowse() {
);
}
- // Discover ⟷ Browse switch, shown on both surfaces so either can return to the other.
- const viewToggle = (
- sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
+ // Sort is tightly bound to the grid below, so on the grid view it lives pinned to the right of the
+ // applied-filter chip row (directly above the cards), not on its own toolbar line — the same idiom as
+ // the Feed's `sortView`. The Discover/Browse toggle moved up to the header (next to the scope pill).
+ const sortView = (
+ setSort(next ?? PLEX_SORT_FALLBACK)}
+ label={t("plex.filter.sort")}
+ ascLabel={t("plex.filter.dir.asc")}
+ descLabel={t("plex.filter.dir.desc")}
/>
);
if (sub.view.kind === "discover") {
return (
-
{viewToggle}
);
}
+ // The header-pill "Playlist" / "Collection" browse surfaces: a grid of tiles into each area. A
+ // playlist tile opens that playlist; a collection tile filters the title grid by that collection.
+ if (sub.view.kind === "playlists") {
+ return (
+
+
+ {/* Watch state — a top-level toolbar pill next to the count (moved out of the rail),
+ the same idiom as the feed's Watch-state FilterSelect. */}
+ ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
+ selected={[show]}
+ onChange={(next) => setShow(next[0] ?? "all")}
/>
+ {/* The controls row sits directly above the grid: the applied-filter chips flow from the
+ left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort +
+ the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
+
+ {/* The active personal view (if any) leads the applied-filter row, like the feed. */}
+
+
+
{sortView}
+
-
-
{personCards.length > 0 && (
{personCards.map((p) => (
diff --git a/frontend/src/components/PlexCollectionEditor.tsx b/frontend/src/components/PlexCollectionEditor.tsx
index 4bb88a97..9b3df1ee 100644
--- a/frontend/src/components/PlexCollectionEditor.tsx
+++ b/frontend/src/components/PlexCollectionEditor.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react";
import { api } from "../lib/api";
+import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
@@ -100,6 +101,9 @@ export default function PlexCollectionEditor({
setNewName("");
setMembers((prev) => new Set(prev).add(col.id));
refresh();
+ } catch (e) {
+ // Surfaces the 409 when the name duplicates an existing collection (Plex conflates same names).
+ notifyActionError(e, t("plex.collection.editFailed"));
} finally {
setBusy(null);
}
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx
new file mode 100644
index 00000000..e7496b67
--- /dev/null
+++ b/frontend/src/components/PlexCollectionView.tsx
@@ -0,0 +1,423 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "lucide-react";
+import { qk } from "../lib/queryKeys";
+import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
+import { formatRuntime } from "../lib/format";
+import { notifyActionError } from "../lib/actionErrors";
+import { useDebounced } from "../lib/useDebounced";
+import { useConfirm } from "./ConfirmProvider";
+import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
+import { PlexPosterCard } from "./plexCards";
+import { toggleWatchState } from "../lib/plexWatch";
+import { LoadingState, StateMessage } from "./QueryState";
+
+// The Collection info page (ask B): a hero (art/title/cert/year-range/count/runtime/summary) + the
+// ordered member grid, a Play-all / Shuffle over the playable leaves, and — for a NON-external,
+// admin-enabled collection — rename / delete / add / remove, all write-through to Plex (the mutation
+// endpoints round-trip to Plex, so each edit awaits and re-fetches; no optimism). Read-only ones (smart
+// / imdb / tmdb / … auto-lists) are marked with a lock.
+function shuffled(a: string[]): string[] {
+ const r = [...a];
+ for (let i = r.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ const tmp = r[i]!;
+ r[i] = r[j]!;
+ r[j] = tmp;
+ }
+ return r;
+}
+
+export default function PlexCollectionView({
+ collectionId,
+ onBack,
+ onPlay,
+ onOpenCard,
+ onInfoCard,
+}: {
+ collectionId: string;
+ onBack: () => void;
+ onPlay: (rk: string, queue: string[]) => void;
+ onOpenCard: (card: PlexCard) => void;
+ onInfoCard: (card: PlexCard) => void;
+}) {
+ const { t } = useTranslation();
+ const qc = useQueryClient();
+ const confirm = useConfirm();
+ const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
+ const q = useQuery({
+ queryKey: qk.plexCollections("detail", collectionId),
+ queryFn: () => api.plexCollection(collectionId),
+ });
+ const d = q.data;
+
+ const [expanded, setExpanded] = useState(false);
+ const [renaming, setRenaming] = useState(false);
+ const [name, setName] = useState("");
+ const [adding, setAdding] = useState(false);
+ const [busy, setBusy] = useState(false);
+
+ const invalidate = () => {
+ qc.invalidateQueries({ queryKey: qk.plexCollections() });
+ qc.invalidateQueries({ queryKey: qk.plexLibrary() });
+ };
+
+ const items = d?.items ?? [];
+ // The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly.
+ const playable = items.filter((c) => c.type !== "show").map((c) => c.id);
+ const canEdit = isAdmin && !!d?.can_edit;
+ // A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
+ // isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
+ const canEnableEdit = isAdmin && !d?.can_edit && !d?.editable && !!d && !isIntrinsicReadOnly(d);
+ const readOnly = !d?.can_edit && !canEnableEdit; // external / smart auto-list
+
+ const toggleWatched = (card: PlexCard) =>
+ toggleWatchState(card, t, {
+ optimistic: (next) => {
+ qc.setQueryData(qk.plexCollections("detail", collectionId), (old) =>
+ old
+ ? {
+ ...old,
+ items: old.items.map((c) => (c.id === card.id ? { ...c, status: next } : c)),
+ }
+ : old,
+ );
+ },
+ resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }),
+ });
+
+ async function doRename() {
+ const title = name.trim();
+ if (!title || !d || title === d.title) {
+ setRenaming(false);
+ return;
+ }
+ setBusy(true);
+ try {
+ await api.plexRenameCollection(collectionId, title);
+ invalidate();
+ setRenaming(false);
+ } catch (e) {
+ notifyActionError(e, t("plex.collection.editFailed"));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function doDelete() {
+ if (!d) return;
+ if (!(await confirm({ message: t("plex.collection.deleteConfirm", { title: d.title }) })))
+ return;
+ setBusy(true);
+ try {
+ await api.plexDeleteCollection(collectionId);
+ invalidate();
+ onBack();
+ } catch (e) {
+ notifyActionError(e, t("plex.collection.editFailed"));
+ setBusy(false);
+ }
+ }
+
+ async function doRemove(card: PlexCard) {
+ // Write-through to the shared Plex library — confirm first, matching the delete-collection guard.
+ if (!(await confirm({ message: t("plex.collection.removeConfirm", { title: card.title }) })))
+ return;
+ setBusy(true);
+ try {
+ await api.plexCollectionRemoveItem(collectionId, card.id);
+ invalidate();
+ } catch (e) {
+ notifyActionError(e, t("plex.collection.editFailed"));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ async function enableEdit() {
+ setBusy(true);
+ try {
+ await api.plexSetCollectionEditable(collectionId, true);
+ invalidate();
+ } catch (e) {
+ notifyActionError(e, t("plex.collection.editFailed"));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ const meta = [
+ d?.year_min != null &&
+ (d.year_max && d.year_max !== d.year_min ? `${d.year_min} — ${d.year_max}` : `${d.year_min}`),
+ d?.content_rating,
+ d && t("plex.browse.items", { count: d.child_count ?? items.length }),
+ formatRuntime(d?.total_runtime_s),
+ ].filter(Boolean) as string[];
+
+ return (
+