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 ( +
+ sub.open({ kind: "playlist", id })} /> +
+ ); + } + if (sub.view.kind === "collections") { + return ( +
+ sub.open({ kind: "collection", id })} /> +
+ ); + } return (
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put while only the poster grid scrolls. */} -
- - {viewToggle} - {/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */} -
- setSort(next ?? PLEX_SORT_FALLBACK)} - label={t("plex.filter.sort")} - ascLabel={t("plex.filter.dir.asc")} - descLabel={t("plex.filter.dir.desc")} +
+
+ + {/* 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 ( +
+ + + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d ? ( + + ) : ( + <> + 0} + onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)} + customizeMenu={null} + > + {renaming ? ( + setName(e.target.value)} + onBlur={doRename} + onKeyDown={(e) => { + if (e.key === "Enter") doRename(); + if (e.key === "Escape") setRenaming(false); + }} + aria-label={t("plex.collection.rename")} + className="glass-card w-full rounded-lg px-2 py-1 text-2xl font-semibold focus:outline-hidden focus:ring-2 focus:ring-accent/40" + /> + ) : ( +
+

{d.title}

+ {readOnly && ( + + + {t("plex.collection.readOnly")} + + )} +
+ )} + + {meta.length > 0 && ( +
+ {meta.map((m, i) => ( + + {i > 0 && "· "} + {m} + + ))} +
+ )} + + {d.summary && ( +
+

+ {d.summary} +

+ +
+ )} + +
+ {playable.length > 0 && ( + <> + onPlay(playable[0]!, playable)} + icon={Play} + label={t("plex.collection.playAll")} + /> + { + const order = shuffled(playable); + onPlay(order[0]!, order); + }} + icon={Shuffle} + label={t("plex.collection.shuffle")} + /> + + )} + {canEdit && ( + <> + { + setName(d.title); + setRenaming(true); + }} + icon={Pencil} + label={t("plex.collection.rename")} + /> + setAdding((v) => !v)} + icon={Plus} + label={t("plex.collection.addItem")} + /> + + + )} + {canEnableEdit && ( + + )} +
+
+ + {adding && canEdit && ( + c.type === "show") ? "show" : "movie"} + existing={new Set(items.map((c) => c.id))} + onAdded={invalidate} + onClose={() => setAdding(false)} + /> + )} + + {items.length === 0 ? ( +

{t("plex.collection.empty")}

+ ) : ( +
+ {items.map((c) => ( +
+ onOpenCard(c)} + onInfo={() => onInfoCard(c)} + onToggleWatched={toggleWatched} + /> + {canEdit && ( + + )} +
+ ))} +
+ )} + + )} +
+ ); +} + +// A compact search-and-add panel: search the collection's library, click a result to add it (write- +// through to Plex). Stays open so several items can be added in a row; already-present items are marked. +function AddItemPanel({ + collectionId, + scope, + existing, + onAdded, + onClose, +}: { + collectionId: string; + scope: string; + existing: Set; + onAdded: () => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [term, setTerm] = useState(""); + const debounced = useDebounced(term.trim(), 300); + const [added, setAdded] = useState>(new Set()); + const q = useQuery({ + queryKey: qk.plexLibrary("collection-add", scope, debounced), + queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }), + enabled: debounced.length > 0, + }); + const results = q.data?.items ?? []; + + async function add(card: PlexCard) { + try { + await api.plexCollectionAddItem(collectionId, card.id); + setAdded((s) => new Set(s).add(card.id)); + onAdded(); + } catch (e) { + notifyActionError(e, t("plex.collection.editFailed")); + } + } + + return ( +
+
+ + setTerm(e.target.value)} + placeholder={t("plex.collection.addSearch")} + aria-label={t("plex.collection.addSearch")} + className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" + /> + +
+ {debounced.length === 0 ? ( +

{t("plex.collection.addHint")}

+ ) : q.isLoading ? ( + + ) : q.isError ? ( +

{t("common.loadError")}

+ ) : results.length === 0 ? ( +

{t("plex.collection.addNoResults")}

+ ) : ( +
    + {results.map((c) => { + const inColl = existing.has(c.id) || added.has(c.id); + return ( +
  • + + + {c.title} + {c.year != null && · {c.year}} + + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx index 7a204232..548f5314 100644 --- a/frontend/src/components/PlexDiscover.dom.test.tsx +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -42,6 +42,17 @@ vi.mock("../lib/api", () => ({ plexSurprise: (...a: unknown[]) => plexSurprise(...a), plexSetState: (...a: unknown[]) => plexSetState(...a), }, + // PlexDiscover now reads the active filters via usePlex (PlexProvider); its context default needs + // EMPTY_PLEX_FILTERS at import time. No provider is rendered, so the default (empty) filters apply. + EMPTY_PLEX_FILTERS: { + genres: [], + contentRatings: [], + directors: [], + actors: [], + studios: [], + keywords: [], + collections: [], + }, })); import PlexDiscover from "./PlexDiscover"; @@ -66,8 +77,9 @@ const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({ const fullPayload = { scope: "both", continue_watching: [card("cw1", "Resume Me")], + recently_watched: [card("rw1", "Watched Me")], recently_added: [card("ra1", "Just Added")], - genres: [{ value: "Drama", count: 12 }], + genres: [{ value: "Drama", count: 12, art: [] }], because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] }, }; @@ -92,6 +104,7 @@ describe("PlexDiscover", () => { />, ); await screen.findByText("plex.discover.continueWatching"); + expect(screen.getByText("plex.discover.recentlyWatched")).toBeTruthy(); expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy(); expect(screen.getByText("plex.discover.surprise")).toBeTruthy(); expect(screen.getByText("plex.discover.byGenre")).toBeTruthy(); @@ -99,10 +112,53 @@ describe("PlexDiscover", () => { expect(screen.getByText("plex.discover.becauseWatched:Anchor")).toBeTruthy(); // A card from each rail is present (title rendered in the poster label). expect(screen.getAllByText("Resume Me").length).toBeGreaterThan(0); + expect(screen.getAllByText("Watched Me").length).toBeGreaterThan(0); expect(screen.getAllByText("Just Added").length).toBeGreaterThan(0); expect(screen.getAllByText("Random Pick").length).toBeGreaterThan(0); expect(screen.getAllByText("Rec One").length).toBeGreaterThan(0); - expect(plexDiscover).toHaveBeenCalledWith("both", "en"); + // Now called with the active filters too (empty default here, no provider mounted). + expect(plexDiscover).toHaveBeenCalledWith( + "both", + "en", + expect.objectContaining({ genres: [] }), + ); + }); + + it("registers the discover + surprise queries keyed by the active lang", async () => { + // The real cache-separation guard (the queryKeys factory test only pins that a lang ARG survives the + // spread): inspect the actual keys the component registered. A regression dropping lang from either + // qk call would collide HU/EN in the cache even while the queryFn still fetches the right language. + plexDiscover.mockResolvedValue(fullPayload); + plexSurprise.mockResolvedValue({ scope: "both", items: [] }); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + await screen.findByText("plex.discover.continueWatching"); + const keys = qc + .getQueryCache() + .getAll() + .map((q) => q.queryKey); + expect(keys).toContainEqual([ + "plex-discover", + "both", + "en", + expect.objectContaining({ genres: [] }), + ]); + expect(keys).toContainEqual([ + "plex-surprise", + "both", + "en", + expect.objectContaining({ genres: [] }), + ]); }); it("fires onGenre with the tile's value", async () => { @@ -162,6 +218,7 @@ describe("PlexDiscover", () => { plexDiscover.mockResolvedValue({ scope: "both", continue_watching: [], + recently_watched: [], recently_added: [], genres: [], because_you_watched: null, diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index ad19b79b..4b23af4f 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -4,8 +4,10 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { RotateCcw } from "lucide-react"; import { qk } from "../lib/queryKeys"; import { api, type PlexCard, type PlexDiscover as PlexDiscoverData } from "../lib/api"; +import PosterTile from "./PosterTile"; import { plexLang } from "../i18n"; import { toggleWatchState } from "../lib/plexWatch"; +import { usePlex } from "./PlexProvider"; import { Carousel } from "./Carousel"; import { PlexPosterCard } from "./plexCards"; import { LoadingState, StateMessage } from "./QueryState"; @@ -17,14 +19,6 @@ import { LoadingState, StateMessage } from "./QueryState"; // info / watched-toggle handlers come from the shell (PlexBrowse) so a rail card behaves exactly like // a grid card; the watched-toggle optimistically flips THIS surface's cache too. -// A stable, pleasant hue per genre string (no art to collage yet — colored tiles stand in). Hashing -// keeps a genre the same color across renders/sessions without storing anything. -function genreHue(name: string): number { - let h = 0; - for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360; - return h; -} - function CardRail({ title, ariaLabel, @@ -89,14 +83,17 @@ export default function PlexDiscover({ const { t } = useTranslation(); const qc = useQueryClient(); const lang = plexLang(); + // The active sidebar filters narrow every rail, so Discover matches the Browse grid. They're in the + // query keys so a filter change refetches (and the watched-toggle setQueryData below keys the same). + const { filters } = usePlex(); const q = useQuery({ - queryKey: qk.plexDiscover(scope, lang), - queryFn: () => api.plexDiscover(scope, lang), + queryKey: qk.plexDiscover(scope, lang, filters), + queryFn: () => api.plexDiscover(scope, lang, filters), }); // Surprise-me is its own query so the ↻ button can redraw it without refetching the whole surface. const surpriseQ = useQuery({ - queryKey: qk.plexSurprise(scope), - queryFn: () => api.plexSurprise(scope), + queryKey: qk.plexSurprise(scope, lang, filters), + queryFn: () => api.plexSurprise(scope, undefined, filters, lang), staleTime: 0, }); @@ -107,7 +104,7 @@ export default function PlexDiscover({ optimistic: (next) => { const flip = (cards: PlexCard[]) => cards.map((c) => (c.id === card.id ? { ...c, status: next } : c)); - qc.setQueryData(qk.plexDiscover(scope, lang), (old) => + qc.setQueryData(qk.plexDiscover(scope, lang, filters), (old) => old ? { ...old, @@ -119,8 +116,9 @@ export default function PlexDiscover({ } : old, ); - qc.setQueryData<{ scope: string; items: PlexCard[] }>(qk.plexSurprise(scope), (old) => - old ? { ...old, items: flip(old.items) } : old, + qc.setQueryData<{ scope: string; items: PlexCard[] }>( + qk.plexSurprise(scope, lang, filters), + (old) => (old ? { ...old, items: flip(old.items) } : old), ); }, resync: () => { @@ -145,6 +143,7 @@ export default function PlexDiscover({ const bw = d.because_you_watched; const nothing = d.continue_watching.length === 0 && + d.recently_watched.length === 0 && d.recently_added.length === 0 && d.genres.length === 0 && surprise.length === 0 && @@ -167,6 +166,15 @@ export default function PlexDiscover({ /> )} + {d.recently_watched.length > 0 && ( + + )} + {d.recently_added.length > 0 && (

{t("plex.discover.byGenre")}

- {d.genres.map((g) => { - const hue = genreHue(g.value); - return ( - - ); - })} + {d.genres.map((g) => ( + onGenre(g.value)} + className="h-24" + /> + ))}
)} diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 362d5d48..61cc1608 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -693,7 +693,12 @@ export default function PlexInfo({ {onFilter && ( -
- {(playlistsQ.data?.playlists ?? []).length === 0 ? ( -

{t("plex.playlist.none")}

- ) : ( -
- {playlistsQ.data!.playlists.map((p) => ( - - ))} -
- )} - - ), - }, - { - id: "watch", - title: t("plex.filter.show"), - body: ( - ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))} - value={show} - onChange={setShow} - /> - ), + body: , }, ...(peopleActive ? [ @@ -205,9 +151,11 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl title: t("plex.filter.collection"), body: ( patch({ collection: id, collectionTitle: title })} + values={filters.collections} + titles={filters.collectionTitles} + mode={filters.collectionMode ?? "any"} + onChange={(collections, collectionTitles) => patch({ collections, collectionTitles })} + onModeChange={(m) => patch({ collectionMode: m })} /> ), }, @@ -351,44 +299,122 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl ]; return ( - } - count={activeCount} - collapsed={collapsed} - onToggleCollapse={onToggleCollapse} - layout={layout} - setLayout={setLayout} - items={items} - actions={ - anyActive ? ( - - ) : undefined + <> + } + count={activeCount} + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + layout={layout} + setLayout={setLayout} + items={items} + // The Plex widgets are simple label + control rows now, not collapsible islands (matches the + // feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it. + collapsible={false} + // Saved ("personal") views: header icon → quick-pick popover, title → the manager modal. Hidden + // for the demo account. Same shared components as the feed rail (SavedViewsProvider switches source). + iconExpanded={ + isDemo ? undefined : ( + } /> + ) + } + onTitleClick={isDemo ? undefined : openModal} + titleActionLabel={isDemo ? undefined : t("views.manageTitle")} + /> + {/* The manager modal lives here (rail is always mounted on plex); Modal portals to . */} + {modalOpen && } + + ); +} + +// The playlists section: a combobox that stays compact (just a field + chevron) until opened, instead +// of an always-expanded list that grew the rail with every playlist. Picking a playlist opens it (the +// PlexBrowse `playlistOpen` hand-off); typing a name that doesn't exist yet offers a "Create …" row so +// you can still add one — the same reuse-the-Combobox idea as the feed's "Your tags" widget. `selected` +// stays empty: this picks an action (open / create), it doesn't hold a persistent selection. +const CREATE_PLAYLIST = "__create__"; +function PlaylistPicker() { + const { t } = useTranslation(); + const { setPlaylistOpen: onOpenPlaylist } = usePlexActions(); + const qc = useQueryClient(); + const [query, setQuery] = useState(""); + const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() }); + const playlists = playlistsQ.data?.playlists ?? []; + + const q = query.trim(); + const ql = q.toLowerCase(); + const matches = ql ? playlists.filter((p) => p.title.toLowerCase().includes(ql)) : playlists; + const exact = playlists.some((p) => p.title.toLowerCase() === ql); + const options: ComboboxOption[] = [ + ...matches.map((p) => ({ value: `pl:${p.id}`, label: p.title, count: p.item_count })), + // Offer to create only when the typed name isn't already a playlist. + ...(q && !exact ? [{ value: CREATE_PLAYLIST, label: q }] : []), + ]; + + async function createPlaylist(title: string) { + const pl = await api.plexCreatePlaylist(title); + qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); + onOpenPlaylist(pl.id); + } + + return ( + + o.value === CREATE_PLAYLIST ? ( + <> + + + {t("plex.playlist.createNamed", { name: o.label })} + + + ) : ( + <> + + {o.label} + {o.count != null && ( + {o.count} + )} + + ) } + onChange={(next) => { + const v = next[0]; + if (!v) return; + if (v === CREATE_PLAYLIST) createPlaylist(q); + else onOpenPlaylist(Number(v.slice(3))); + }} /> ); } -// The collection filter (E5 S7 → combobox rework): a single-select combi-input matching the -// genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The -// generic owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on -// `open`) and maps collections to options. Picking sets the filter (id + title for the chip); clearing -// the chip drops it. +// The collection filter (E5 S7 → combobox; now multi-select): a combi-input matching the genre/studio +// pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The generic +// owns the field/keyboard/ARIA + the any/all match footer; this wrapper owns the query→fetch (gated on +// `open`) and maps collections to options. A title can belong to several collections, so multiple can +// be picked and combined with Any/All. Titles are carried alongside the ids for the active-filter chips. function CollectionPicker({ - value, - title, - onPick, + values, + titles, + mode, + onChange, + onModeChange, }: { - value: string | null | undefined; - title: string | null | undefined; - onPick: (id: string | null, title: string | null) => void; + values: string[]; + titles: Record | undefined; + mode: "any" | "all"; + onChange: (values: string[], titles: Record | undefined) => void; + onModeChange: (mode: "any" | "all") => void; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -404,25 +430,33 @@ function CollectionPicker({ label: c.title, count: c.child_count ?? undefined, })); + // Combine the fetched labels with the ones already stored so the trip through onChange keeps every + // selected collection's title (the fetched list is narrowed by the current search). + const labelMap: Record = { ...titles }; + for (const o of options) if (o.label) labelMap[o.value] = o.label; return ( onModeChange(m === "and" ? "all" : "any")} onChange={(next) => { - const id = next[0] ?? null; - const picked = options.find((o) => o.value === id); - onPick(id, id ? (picked?.label ?? title ?? id) : null); + // Carry only the selected ids' titles forward (drop titles for de-selected ones). Collapse an + // empty map to undefined so a clear-all key-matches the pristine state (an empty {} still hashes + // differently from an absent key), mirroring the chip-row remove handler. + const nextTitles: Record = {}; + for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id; + onChange(next, next.length ? nextTitles : undefined); }} /> ); diff --git a/frontend/src/components/PlexViewBridge.tsx b/frontend/src/components/PlexViewBridge.tsx new file mode 100644 index 00000000..99f2cb41 --- /dev/null +++ b/frontend/src/components/PlexViewBridge.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, useState, type ReactNode } from "react"; +import { type Sub } from "./plexSubUrl"; + +// A one-way bridge so the always-mounted Header can host the Plex Discover ⟷ Browse toggle even though +// the sub-view state lives in (and is owned by) the plex-only PlexBrowse. PlexBrowse PUBLISHES its +// current top-level view + opener here (on mount and on view change) and clears it on unmount; the +// Header READS it to render the toggle beside the library-scope pill. +// +// Why a bridge and not a global sub-view provider: PlexBrowse's `useHistorySubview` REMOUNTS every time +// you enter the plex page, re-seeding the view from history (a page switch clears ?v + drops _sub, so +// each plex entry starts at the Discover root). Lifting that hook to an always-mounted provider would +// lose the remount and let the view go stale across navigation. Keeping it in PlexBrowse preserves the +// reseed; the bridge only mirrors the small slice the header needs. +export type PlexViewControls = { + kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it + open: (v: Sub) => void; +}; + +const ReadCtx = createContext(null); +const PublishCtx = createContext<(c: PlexViewControls | null) => void>(() => {}); + +export function PlexViewBridge({ children }: { children: ReactNode }) { + const [controls, setControls] = useState(null); + return ( + + {children} + + ); +} + +export const usePlexViewControls = () => useContext(ReadCtx); +export const usePublishPlexView = () => useContext(PublishCtx); diff --git a/frontend/src/components/PlexViewToggle.dom.test.tsx b/frontend/src/components/PlexViewToggle.dom.test.tsx new file mode 100644 index 00000000..41370aea --- /dev/null +++ b/frontend/src/components/PlexViewToggle.dom.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +// +// Contract of the header Discover ⟷ Browse toggle. The floating header wrapper is pointer-events-none, +// so the toggle MUST re-enable pointer events on its root or it is click-dead (the bug this test guards +// against). Also: it only renders on the two title-browse views, marks the active one, and drives +// PlexBrowse's sub-view through the bridge. i18n is key-passthrough; the bridge is mocked. +import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +const controls = { kind: "discover" as string, open: vi.fn() }; +vi.mock("./PlexViewBridge", () => ({ + usePlexViewControls: () => controls, +})); + +import PlexViewToggle from "./PlexViewToggle"; + +beforeEach(() => { + controls.kind = "discover"; + controls.open.mockReset(); +}); +afterEach(cleanup); + +describe("PlexViewToggle", () => { + it("re-enables pointer events on its root (the header wrapper is pointer-events-none)", () => { + const { container } = render(); + // Without this class the whole toggle is click-dead inside the pointer-events-none header. + expect(container.querySelector(".pointer-events-auto")).not.toBeNull(); + }); + + it("renders the two options with the current view pressed", () => { + render(); + const btns = [ + ...screen.getByRole("group", { name: "plex.view.label" }).querySelectorAll("button"), + ]; + expect(btns.map((b) => b.textContent)).toEqual(["plex.discover.tab", "plex.view.browse"]); + const pressed = btns + .filter((b) => b.getAttribute("aria-pressed") === "true") + .map((b) => b.textContent); + expect(pressed).toEqual(["plex.discover.tab"]); + }); + + it("opens the grid view when Browse is picked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "plex.view.browse" })); + expect(controls.open).toHaveBeenCalledWith({ kind: "grid" }); + }); + + it("opens the discover view when Discover is picked from the grid", () => { + controls.kind = "grid"; + render(); + fireEvent.click(screen.getByRole("button", { name: "plex.discover.tab" })); + expect(controls.open).toHaveBeenCalledWith({ kind: "discover" }); + }); + + it("renders nothing on a deeper view (no Discover/Browse choice there)", () => { + controls.kind = "playlists"; + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/frontend/src/components/PlexViewToggle.tsx b/frontend/src/components/PlexViewToggle.tsx new file mode 100644 index 00000000..61a60582 --- /dev/null +++ b/frontend/src/components/PlexViewToggle.tsx @@ -0,0 +1,30 @@ +import { useTranslation } from "react-i18next"; +import SegmentedToggle from "./SegmentedToggle"; +import { usePlexViewControls } from "./PlexViewBridge"; + +// The Discover ⟷ Browse switch, hosted in the header next to the library-scope pill (All/Movies/Shows). +// It reflects + drives PlexBrowse's top-level sub-view through the PlexViewBridge, and hides itself on +// the deeper views (a show/info/person page has no Discover/Browse choice). +export default function PlexViewToggle() { + const { t } = useTranslation(); + const ctl = usePlexViewControls(); + // Only the two title-browse views host the Discover/Browse choice; deeper views (show/info/person/…) + // and the playlists/collections browse surfaces hide it. + if (!ctl || (ctl.kind !== "discover" && ctl.kind !== "grid")) return null; + // The floating header wrapper is pointer-events-none (so clicks fall through its empty area to the + // content below); every interactive child must re-enable pointer events or it is click-dead — the + // sibling PlexScopeToggle does the same. + return ( + ctl.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })} + /> + ); +} diff --git a/frontend/src/components/PosterTile.tsx b/frontend/src/components/PosterTile.tsx new file mode 100644 index 00000000..82805652 --- /dev/null +++ b/frontend/src/components/PosterTile.tsx @@ -0,0 +1,61 @@ +import { type ReactNode } from "react"; +import { hueOf, hueTileGradient } from "../lib/hueTile"; + +// A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}), +// the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable +// (a full-bleed poster collage drowned it). A group with no art falls back to an all-colour tile, never +// blank. Shared by the Discover genre tiles and the Playlist/Collection browse tiles. +export default function PosterTile({ + title, + subtitle, + topRight, + art, + onClick, + className, +}: { + title: string; + subtitle?: ReactNode; // a bottom sub-line under the title (e.g. "12 items · 4h 30m") + topRight?: ReactNode; // top-right corner content on the colour band (e.g. an icon) + art?: string[]; // up to 2 poster rating_keys; fewer/none falls back to the colour hash + onClick: () => void; + className?: string; // caller sets the height (e.g. "h-20" / "h-28") +}) { + const posters = (art ?? []).slice(0, 2); + const band = hueTileGradient(hueOf(title)); + return ( + + ); +} diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index 496256af..c02db1a1 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -4,9 +4,12 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api, - getActiveAccount, + coercePlexFilters, coerceTagModes, + getActiveAccount, + plexFiltersSignature, type FeedFilters, + type PlexFilters, type SavedView, } from "../lib/api"; import { filtersToParams } from "../lib/urlState"; @@ -14,12 +17,17 @@ import { preferSticky } from "../lib/savedViews"; import { notifyActionError } from "../lib/actionErrors"; import { accountKey, LS, writeJSON } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; +import { useNavigation } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; +import { usePlex, usePlexActions } from "./PlexProvider"; -// A saved view's `filters` is an opaque JSON blob from the DB — the FeedFilters type says nothing about -// what an older build wrote there, so coerce the array/shape fields (and migrate the legacy global -// tagMode) before the blob reaches app state or a serializer. NOT a migration: a view saved against a -// dropped field just loses that filter. +// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider +// switches which module it operates on by the active page (both filter providers are global, so it can +// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and +// on the plex page against plex views without a second provider or an App-layout change. + +// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it +// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration). export const asFilters = (raw: FeedFilters): FeedFilters => ({ ...raw, tags: raw.tags ?? [], @@ -27,13 +35,27 @@ export const asFilters = (raw: FeedFilters): FeedFilters => ({ tagModes: coerceTagModes(raw), }); -// A stable signature of a filter set (only non-default keys, shuffle seed ignored) so a view can be -// matched against the live filters regardless of key order or transient fields. -const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString(); +type ViewSource = "feed" | "plex"; -// Mirror the default view's filters to localStorage so the app can seed them on next load before the -// network returns (read by FeedFiltersProvider). -function syncDefaultMirror(views: SavedView[]): void { +// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable +// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed). +interface ViewConfig { + source: ViewSource; + filters: Record; + setFilters: (f: Record) => void; + keyOf: (f: Record) => string; + asFilters: (raw: Record) => Record; + stripForSave: (f: Record) => Record; + /** Feed only: mirror the default view's filters to localStorage so it seeds before the network returns. */ + syncDefaultMirror?: (views: SavedView[]) => void; +} + +const feedKeyOf = (f: Record) => + filtersToParams(asFilters(f as unknown as FeedFilters)).toString(); +const plexKeyOf = (f: Record) => plexFiltersSignature(coercePlexFilters(f)); + +// Mirror the feed default view's filters to localStorage (read by FeedFiltersProvider on load). +function syncFeedDefaultMirror(views: SavedView[]): void { const key = accountKey(LS.defaultViewFilters, getActiveAccount()); if (!key) return; const def = views.find((v) => v.is_default); @@ -41,26 +63,21 @@ function syncDefaultMirror(views: SavedView[]): void { } export interface SavedViewsContext { + /** Which module these views belong to — the shared UI branches its filter-describer on it. */ + source: ViewSource; views: SavedView[]; loading: boolean; - /** The view whose saved filters EXACTLY match the live filters (null if none) — highlighted as active. */ activeViewId: number | null; - /** The view to name in the chip: the exact match, else the last-applied ("sticky") one it diverged from. */ displayView: SavedView | null; - /** True when a sticky view is shown but the live filters no longer match it (the chip's "modified" dot). */ isDirty: boolean; modalOpen: boolean; openModal: () => void; closeModal: () => void; - /** Apply a view's filters and remember it as the active/sticky one. */ apply: (v: SavedView) => void; - /** Forget the active/sticky view (the chip disappears) without touching the filters. */ clearActive: () => void; - /** Save the CURRENT filters as a new view; returns success so a caller can keep its editor open on fail. */ create: (name: string) => Promise; rename: (id: number, name: string) => Promise; toggleDefault: (v: SavedView) => Promise; - /** Delete a view after confirming; resolves true only when it was actually deleted (false on cancel/fail). */ remove: (v: SavedView) => Promise; reorder: (nextIds: number[]) => Promise; } @@ -71,33 +88,75 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const filters = useFeedFilters(); - const { setFilters } = useFeedFiltersActions(); + const { page } = useNavigation(); - const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews }); + // Both filter providers are global — read both, then use the one the active page owns. + const feedFilters = useFeedFilters(); + const { setFilters: setFeedFilters } = useFeedFiltersActions(); + const { filters: plexFilters } = usePlex(); + const { setFilters: setPlexFilters } = usePlexActions(); + + const cfg: ViewConfig = useMemo(() => { + if (page === "plex") { + return { + source: "plex", + filters: plexFilters as unknown as Record, + setFilters: (f) => setPlexFilters(f as unknown as PlexFilters), + keyOf: plexKeyOf, + asFilters: (raw) => coercePlexFilters(raw) as unknown as Record, + stripForSave: (f) => f, // Plex has no transient field to drop + }; + } + return { + source: "feed", + filters: feedFilters as unknown as Record, + setFilters: (f) => setFeedFilters(f as unknown as FeedFilters), + keyOf: feedKeyOf, + asFilters: (raw) => + asFilters(raw as unknown as FeedFilters) as unknown as Record, + // Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply. + stripForSave: (f) => { + const { seed: _seed, ...rest } = f; + return rest; + }, + syncDefaultMirror: syncFeedDefaultMirror, + }; + }, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]); + + const source = cfg.source; + const viewsQuery = useQuery({ + queryKey: qk.savedViews(source), + queryFn: () => api.savedViews(source), + }); const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]); const [stickyId, setStickyId] = useState(null); const [modalOpen, setModalOpen] = useState(false); - // Keep the default-view mirror (read on load) in sync with the server state. + // Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex, + // and a feed modal shouldn't linger open on plex. useEffect(() => { - if (viewsQuery.data) syncDefaultMirror(viewsQuery.data); - }, [viewsQuery.data]); + setStickyId(null); + setModalOpen(false); + }, [source]); - const currentKey = keyOf(filters); + // Feed only: keep the default-view mirror (read on load) in sync with the server state. Depend on the + // mirror fn (a module-stable ref, or undefined for plex) NOT the whole `cfg` object — cfg re-memoizes + // on every live filter edit, which would fire this synchronous localStorage write far beyond its intent. + const syncMirror = cfg.syncDefaultMirror; + useEffect(() => { + if (viewsQuery.data) syncMirror?.(viewsQuery.data); + }, [viewsQuery.data, syncMirror]); + + const currentKey = cfg.keyOf(cfg.filters); const activeView = useMemo( - // When several views capture identical filters, prefer the one the user explicitly applied - // (sticky) so apply(B) isn't silently re-attributed to an earlier duplicate A; else the first. () => preferSticky( - views.filter((v) => keyOf(v.filters) === currentKey), + views.filter((v) => cfg.keyOf(v.filters) === currentKey), stickyId, ), - [views, currentKey, stickyId], + [views, currentKey, stickyId, cfg], ); - // An exact match becomes the sticky selection, so filters that happen to equal a view (deep-link, - // manual, or an applied view) name that view in the chip and keep naming it after a later tweak. useEffect(() => { if (activeView) setStickyId(activeView.id); }, [activeView]); @@ -109,7 +168,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { const displayView = activeView ?? stickyView; const isDirty = displayView != null && activeView == null; - const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() }); + const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews(source) }); async function runViewMutation(fn: () => Promise, failMsg: string): Promise { try { await fn(); @@ -123,6 +182,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { } const value: SavedViewsContext = { + source, views, loading: viewsQuery.isLoading, activeViewId: activeView?.id ?? null, @@ -132,16 +192,17 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { openModal: () => setModalOpen(true), closeModal: () => setModalOpen(false), apply: (v) => { - setFilters(asFilters(v.filters)); + cfg.setFilters(cfg.asFilters(v.filters)); setStickyId(v.id); }, clearActive: () => setStickyId(null), create: async (name) => { const clean = name.trim(); if (!clean) return false; - // Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply. - const { seed: _seed, ...rest } = filters; - return runViewMutation(() => api.createView(clean, rest), t("views.actionFailed")); + return runViewMutation( + () => api.createView(clean, cfg.stripForSave(cfg.filters), source), + t("views.actionFailed"), + ); }, rename: async (id, name) => { const next = name.trim(); @@ -164,10 +225,8 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed")); }, reorder: async (nextIds) => { - // Optimistic reorder so the drop sticks visually before the round-trip; the refetch reverts to - // the server's truth on failure (with a toast, so it doesn't silently snap back). qc.setQueryData( - qk.savedViews(), + qk.savedViews(source), nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean), ); await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed")); diff --git a/frontend/src/components/plexBrowseLists.tsx b/frontend/src/components/plexBrowseLists.tsx new file mode 100644 index 00000000..e8f54a3a --- /dev/null +++ b/frontend/src/components/plexBrowseLists.tsx @@ -0,0 +1,275 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react"; +import { qk } from "../lib/queryKeys"; +import { + api, + HttpError, + isIntrinsicReadOnly, + type PlexCard, + type PlexCollection, +} from "../lib/api"; +import { formatRuntime } from "../lib/format"; +import { notifyActionError } from "../lib/actionErrors"; +import { notify } from "../lib/notifications"; +import { useDebounced } from "../lib/useDebounced"; +import { LoadingState, StateMessage } from "./QueryState"; +import PosterTile from "./PosterTile"; + +// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only +// collections have no movie runtime). +function collectionMeta(c: PlexCollection, t: TFunction): string { + const parts: string[] = []; + if (c.child_count != null) parts.push(t("plex.browse.items", { count: c.child_count })); + const rt = formatRuntime(c.total_runtime_s); + if (rt) parts.push(rt); + return parts.join(" · "); +} + +// The Playlist / Collection browse surfaces reached from the header pill (Piece 2): a grid of tiles, +// each a way INTO that area — a playlist tile opens the playlist view, a collection tile filters the +// title grid by that collection. Both reuse the existing list endpoints (plexPlaylists / plexCollections +// union), so this is a pure front-end surface. Collection tiles show a poster collage of their top +// films (PosterTile), falling back to a title-hash colour; playlists have no server art yet, so they +// take the colour fallback. + +const GRID = "grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"; + +// A grid of the user's Plex playlists; a tile opens that playlist's view. +export function PlexPlaylistsBrowse({ onOpen }: { onOpen: (id: number) => void }) { + const { t } = useTranslation(); + const q = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() }); + const playlists = q.data?.playlists ?? []; + + if (q.isError && !q.data) { + return ( + q.refetch()}> + {t("common.loadError")} + + ); + } + if (q.isLoading) return ; + if (playlists.length === 0) + return

{t("plex.playlist.none")}

; + + return ( +
+ {playlists.map((p) => ( + } + onClick={() => onOpen(p.id)} + className="h-28" + /> + ))} +
+ ); +} + +// The "New collection" panel (admin, ask B-route): name (rejected if it duplicates an existing one — +// Plex conflates same-named collections) + a search to pick the FIRST title, since Plex can't create an +// empty collection. Creating opens the new collection's detail page (onCreated → onPick). +function NewCollectionPanel({ + existingNames, + onCreated, + onClose, +}: { + existingNames: Set; + onCreated: (rk: string) => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(""); + const [term, setTerm] = useState(""); + const [busy, setBusy] = useState(false); + const debounced = useDebounced(term.trim(), 300); + const trimmed = name.trim(); + // Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the + // backend enforces uniqueness PER library (Plex only conflates same-named collections within one + // section). So a match here is a hint, not a hard block — it must not gate creation, or a valid + // same-name collection in another library would be un-creatable. The backend's 409 is authoritative. + const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase()); + const nameOk = trimmed.length > 0; + const q = useQuery({ + queryKey: qk.plexLibrary("collection-new", debounced), + queryFn: () => api.plexLibrary({ scope: "both", q: debounced, limit: 12 }), + enabled: nameOk && debounced.length > 0, + }); + const results = q.data?.items ?? []; + + async function create(card: PlexCard) { + if (!nameOk || busy) return; + setBusy(true); + try { + const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library + onCreated(col.id); + } catch (e) { + // The backend is authoritative on per-library name uniqueness — localize its 409 to nameTaken + // (notifyActionError would otherwise surface the server's raw English detail). + if (e instanceof HttpError && e.status === 409) { + notify({ level: "error", message: t("plex.collection.nameTaken") }); + } else { + notifyActionError(e, t("plex.collection.editFailed")); + } + setBusy(false); + } + } + + return ( +
+
+

{t("plex.collection.newCollection")}

+ +
+ setName(e.target.value)} + placeholder={t("plex.collection.namePlaceholder")} + aria-label={t("plex.collection.newCollection")} + className="glass-card w-full rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" + /> + {dupHint ? ( +

{t("plex.collection.nameMaybeTaken")}

+ ) : ( +

{t("plex.collection.newHint")}

+ )} +
+ + setTerm(e.target.value)} + disabled={!nameOk} + placeholder={t("plex.collection.addSearch")} + aria-label={t("plex.collection.addSearch")} + className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50" + /> +
+ {!nameOk || debounced.length === 0 ? null : q.isLoading ? ( + + ) : q.isError ? ( +

{t("common.loadError")}

+ ) : results.length === 0 ? ( +

{t("plex.collection.addNoResults")}

+ ) : ( +
    + {results.map((c) => ( +
  • + + + {c.title} + {c.year != null && · {c.year}} + + +
  • + ))} +
+ )} +
+ ); +} + +// A grid of Plex collections (searchable — the union can run to hundreds); a tile opens its info page. +export function PlexCollectionsBrowse({ onPick }: { onPick: (id: string, title: string) => void }) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin"; + const [search, setSearch] = useState(""); + const [creating, setCreating] = useState(false); + const searchDeb = useDebounced(search.trim(), 300); + const q = useQuery({ + queryKey: qk.plexCollections("union", searchDeb), + queryFn: () => api.plexCollections(undefined, searchDeb || undefined), + }); + const collections = useMemo(() => q.data?.collections ?? [], [q.data?.collections]); + const existingNames = useMemo( + () => new Set(collections.map((c) => c.title.trim().toLowerCase())), + [collections], + ); + + return ( +
+
+ setSearch(e.target.value)} + placeholder={t("plex.filter.collectionSearch")} + aria-label={t("plex.filter.collectionSearch")} + className="glass-card w-full max-w-sm rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" + /> + {isAdmin && !creating && ( + + )} +
+ {creating && ( + { + setCreating(false); + onPick(rk, ""); + }} + onClose={() => setCreating(false)} + /> + )} + {q.isError && !q.data ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading ? ( + + ) : collections.length === 0 ? ( +

{t("plex.filter.collectionEmpty")}

+ ) : ( +
+ {collections.map((c) => ( + + ) : undefined + } + onClick={() => onPick(c.id, c.title)} + className="h-28" + /> + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/plexSubUrl.test.ts b/frontend/src/components/plexSubUrl.test.ts index 4ed2f3fc..664e92a3 100644 --- a/frontend/src/components/plexSubUrl.test.ts +++ b/frontend/src/components/plexSubUrl.test.ts @@ -6,11 +6,14 @@ import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl"; describe("PLEX_SUB_URL encode", () => { it("mirrors each bookmarkable sub-view to ?v=:", () => { expect(U.encode({ kind: "grid" })).toBe("browse"); // the flat "Browse all" grid (E5 S4) + expect(U.encode({ kind: "playlists" })).toBe("playlists"); // browse-all-playlists (Piece 2) + expect(U.encode({ kind: "collections" })).toBe("collections"); // browse-all-collections (Piece 2) expect(U.encode({ kind: "show", id: "s1" })).toBe("show:s1"); expect(U.encode({ kind: "season", showId: "s1", seasonId: "se2" })).toBe("season:s1:se2"); expect(U.encode({ kind: "info", id: "m9" })).toBe("info:m9"); expect(U.encode({ kind: "person", name: "Jane Doe" })).toBe("person:Jane Doe"); expect(U.encode({ kind: "playlist", id: 12 })).toBe("playlist:12"); + expect(U.encode({ kind: "collection", id: "c5" })).toBe("collection:c5"); }); it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => { @@ -22,11 +25,14 @@ describe("PLEX_SUB_URL encode", () => { describe("PLEX_SUB_URL round-trip", () => { const cases: Sub[] = [ { kind: "grid" }, + { kind: "playlists" }, + { kind: "collections" }, { kind: "show", id: "s1" }, { kind: "season", showId: "s1", seasonId: "se2" }, { kind: "info", id: "m9" }, { kind: "person", name: "Jane Doe" }, { kind: "playlist", id: 12 }, + { kind: "collection", id: "c5" }, ]; it.each(cases)("encode→decode is identity for %o", (view) => { const raw = U.encode(view); @@ -56,10 +62,11 @@ describe("PLEX_SUB_URL decode guards", () => { expect(U.decode("season:s1:se2")).toEqual({ kind: "season", showId: "s1", seasonId: "se2" }); }); - it("requires a non-empty arg for show / info / person", () => { + it("requires a non-empty arg for show / info / person / collection", () => { expect(U.decode("show:")).toBeNull(); expect(U.decode("info:")).toBeNull(); expect(U.decode("person:")).toBeNull(); + expect(U.decode("collection:")).toBeNull(); }); it("rejects an unknown kind or garbage", () => { diff --git a/frontend/src/components/plexSubUrl.ts b/frontend/src/components/plexSubUrl.ts index 381a7bd2..43d0c5b6 100644 --- a/frontend/src/components/plexSubUrl.ts +++ b/frontend/src/components/plexSubUrl.ts @@ -7,12 +7,15 @@ import { parsePositiveIntId } from "../lib/parseId"; export type Sub = | { kind: "discover" } | { kind: "grid" } + | { kind: "playlists" } // browse-all-playlists surface (header pill "Playlist" mode) + | { kind: "collections" } // browse-all-collections surface (header pill "Collection" mode) | { kind: "show"; id: string } | { kind: "season"; showId: string; seasonId: string } | { kind: "player"; id: string; queue?: string[] } | { kind: "info"; id: string } | { kind: "person"; name: string } - | { kind: "playlist"; id: number }; + | { kind: "playlist"; id: number } + | { kind: "collection"; id: string }; // the Collection info page (id = collection rating_key) // Mirror the Plex sub-view to ?v=: so a show / season / movie-info / person / playlist page // — and the flat browse grid — are bookmarkable. Discover is the ROOT landing (E5 S4), so it (and the @@ -25,6 +28,10 @@ export const PLEX_SUB_URL: SubviewUrl = { switch (v.kind) { case "grid": return "browse"; + case "playlists": + return "playlists"; + case "collections": + return "collections"; case "show": return `show:${v.id}`; case "season": @@ -35,6 +42,8 @@ export const PLEX_SUB_URL: SubviewUrl = { return `person:${v.name}`; case "playlist": return `playlist:${v.id}`; + case "collection": + return `collection:${v.id}`; default: return null; // discover (root) + the ephemeral player are not URL-mirrored } @@ -46,6 +55,10 @@ export const PLEX_SUB_URL: SubviewUrl = { switch (kind) { case "browse": return { kind: "grid" }; + case "playlists": + return { kind: "playlists" }; + case "collections": + return { kind: "collections" }; case "show": return arg ? { kind: "show", id: arg } : null; case "info": @@ -58,6 +71,8 @@ export const PLEX_SUB_URL: SubviewUrl = { const id = parsePositiveIntId(arg); return id != null ? { kind: "playlist", id } : null; } + case "collection": + return arg ? { kind: "collection", id: arg } : null; case "season": { const [showId, seasonId] = arg.split(":"); return showId && seasonId ? { kind: "season", showId, seasonId } : null; diff --git a/frontend/src/components/usePlexActiveFilters.dom.test.tsx b/frontend/src/components/usePlexActiveFilters.dom.test.tsx index d141f4bd..9e27da56 100644 --- a/frontend/src/components/usePlexActiveFilters.dom.test.tsx +++ b/frontend/src/components/usePlexActiveFilters.dom.test.tsx @@ -18,7 +18,12 @@ const plexState = { show: "watched", // A non-default sort is present in the shared state — the hook must IGNORE it (no sort chip). sort: { key: "year", dir: "asc" }, - filters: { ...EMPTY_PLEX_FILTERS, genres: ["Action"] }, + filters: { + ...EMPTY_PLEX_FILTERS, + genres: ["Action"], + collections: ["A"], + collectionTitles: { A: "Marvel" }, + }, }; const setShow = vi.fn(); const setFilters = vi.fn(); @@ -40,4 +45,21 @@ describe("usePlexActiveFilters", () => { expect(keys).not.toContain("sort"); expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true); }); + + it("removing the last collection chip prunes its title and collapses collectionTitles to undefined", () => { + setFilters.mockClear(); + const { result } = renderHook(() => usePlexActiveFilters()); + const chip = result.current.find((c) => c.key === "collection:A"); + expect(chip).toBeDefined(); + chip!.remove(); + // patch() merges into the full filters object; assert the two touched fields directly. No residual + // { A: "Marvel" } and no empty {} (which would still split the query-key cache from the pristine + // no-collection state) — collectionTitles must be undefined. + const arg = setFilters.mock.calls[0]![0] as { + collections: string[]; + collectionTitles?: unknown; + }; + expect(arg.collections).toEqual([]); + expect(arg.collectionTitles).toBeUndefined(); + }); }); diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts index fbbb391d..fb3ac03d 100644 --- a/frontend/src/components/usePlexActiveFilters.ts +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -12,11 +12,26 @@ import { usePlex, usePlexActions } from "./PlexProvider"; // removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here — // it's an always-visible control above the grid (S3), like the feed. Lives here, not in lib, because // it reads the Plex provider (lib stays provider-free, like useActiveFilters). +type TFn = (key: string, options?: Record) => string; + export function usePlexActiveFilters(): ActiveFilter[] { const { show, filters } = usePlex(); const { setShow, setFilters } = usePlexActions(); const { t } = useTranslation(); const patch = (p: Partial) => setFilters({ ...filters, ...p }); + return buildPlexActiveFilters(filters, show, patch, setShow, t); +} + +// The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic +// ActiveFilter[]. Extracted so the saved-views manager can DESCRIBE an arbitrary view's captured +// filters (with no-op removes) — the labels stay in one place, no duplication with the live chip row. +export function buildPlexActiveFilters( + filters: PlexFilters, + show: string, + patch: (p: Partial) => void, + setShow: (s: string) => void, + t: TFn, +): ActiveFilter[] { const out: ActiveFilter[] = []; const has = (x: number | null | undefined) => x != null; @@ -102,11 +117,21 @@ export function usePlexActiveFilters(): ActiveFilter[] { remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }), }); } - if (filters.collection) { + for (const c of filters.collections) { out.push({ - key: "collection", - label: filters.collectionTitle ?? t("plex.filter.collection"), - remove: () => patch({ collection: null, collectionTitle: null }), + key: `collection:${c}`, + label: filters.collectionTitles?.[c] ?? t("plex.filter.collection"), + // Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry + // splits the cache from the logically-identical no-collection state (mirrors CollectionPicker). + // Collapse an empty map back to undefined so the last-removed state key-matches the pristine + // one (an empty `{}` still hashes differently from an absent key). + remove: () => { + const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {}; + patch({ + collections: filters.collections.filter((x) => x !== c), + collectionTitles: Object.keys(restTitles).length ? restTitles : undefined, + }); + }, }); } if (show !== "all") { diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 1949c832..835ad1a2 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -2,6 +2,33 @@ "navLabel": "Plex", "backToFeed": "YouTube feed", "backToLibrary": "Back to library", + "collection": { + "playAll": "Play all", + "shuffle": "Shuffle", + "rename": "Rename", + "addItem": "Add title", + "delete": "Delete", + "enableEdit": "Enable editing", + "removeItem": "Remove from collection", + "removeConfirm": "Remove \"{{title}}\" from this collection? This affects all users on Plex.", + "nameMaybeTaken": "A collection with this name may already exist in another library — you can still create it here.", + "readOnly": "Read-only", + "empty": "This collection has no items.", + "more": "More", + "less": "Less", + "addSearch": "Search titles to add…", + "addHint": "Type to search for a title to add.", + "addNoResults": "No matches.", + "add": "Add", + "added": "Added", + "deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.", + "editFailed": "Collection edit failed", + "newCollection": "New collection", + "namePlaceholder": "Collection name", + "nameTaken": "A collection with this name already exists.", + "newHint": "Pick the first title to add — a collection needs at least one.", + "createWith": "Create" + }, "searchPlaceholder": "Search Plex…", "searchField": { "aria": "Search scope", @@ -27,7 +54,9 @@ "scopeOpt": { "both": "All", "movie": "Movies", - "show": "Shows" + "show": "Shows", + "playlists": "Playlists", + "collections": "Collections" }, "show": "Show", "showOpt": { @@ -243,6 +272,8 @@ "playlist": { "section": "Playlists", "newPlaceholder": "New playlist…", + "pickerPlaceholder": "Find or create a playlist…", + "createNamed": "Create \"{{name}}\"", "create": "Create playlist", "none": "No playlists yet.", "deleteConfirm": "Delete the playlist \"{{title}}\"?", @@ -282,9 +313,13 @@ "label": "View", "browse": "Browse" }, + "browse": { + "items": "{{count}} items" + }, "discover": { "tab": "Discover", "continueWatching": "Continue watching", + "recentlyWatched": "Recently watched", "recentlyAdded": "Recently added", "surprise": "Surprise me", "reshuffle": "Reshuffle", diff --git a/frontend/src/i18n/locales/en/views.json b/frontend/src/i18n/locales/en/views.json index 4497730f..b9354ad4 100644 --- a/frontend/src/i18n/locales/en/views.json +++ b/frontend/src/i18n/locales/en/views.json @@ -3,7 +3,7 @@ "namePlaceholder": "View name", "saveAction": "Save", "cancel": "Cancel", - "empty": "No saved views yet. Filter the feed, then save it.", + "empty": "No saved views yet. Apply some filters, then save them.", "apply": "Apply this view", "makeDefault": "Set as default (loads on start)", "isDefault": "Default view — loads on start", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 40f19229..43bb5684 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -2,6 +2,33 @@ "navLabel": "Plex", "backToFeed": "YouTube feed", "backToLibrary": "Vissza a könyvtárhoz", + "collection": { + "playAll": "Összes lejátszása", + "shuffle": "Keverés", + "rename": "Átnevezés", + "addItem": "Cím hozzáadása", + "delete": "Törlés", + "enableEdit": "Szerkesztés engedélyezése", + "removeItem": "Eltávolítás a kollekcióból", + "removeConfirm": "Eltávolítod a(z) „{{title}}” elemet ebből a kollekcióból? Ez a Plexen minden felhasználót érint.", + "nameMaybeTaken": "Lehet, hogy már létezik ilyen nevű kollekció egy másik könyvtárban — itt akkor is létrehozhatod.", + "readOnly": "Csak olvasható", + "empty": "Ennek a kollekciónak nincsenek elemei.", + "more": "Több", + "less": "Kevesebb", + "addSearch": "Keress címet a hozzáadáshoz…", + "addHint": "Gépelj a hozzáadni kívánt cím kereséséhez.", + "addNoResults": "Nincs találat.", + "add": "Hozzáad", + "added": "Hozzáadva", + "deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.", + "editFailed": "A kollekció szerkesztése sikertelen", + "newCollection": "Új kollekció", + "namePlaceholder": "Kollekció neve", + "nameTaken": "Már létezik ilyen nevű kollekció.", + "newHint": "Válaszd ki az első címet — egy kollekcióhoz legalább egy elem kell.", + "createWith": "Létrehozás" + }, "searchPlaceholder": "Keresés a Plexben…", "searchField": { "aria": "Keresési hatókör", @@ -27,7 +54,9 @@ "scopeOpt": { "both": "Mind", "movie": "Filmek", - "show": "Sorozatok" + "show": "Sorozatok", + "playlists": "Listák", + "collections": "Gyűjtemények" }, "show": "Megjelenítés", "showOpt": { @@ -243,6 +272,8 @@ "playlist": { "section": "Lejátszási listák", "newPlaceholder": "Új lista…", + "pickerPlaceholder": "Lista keresése vagy létrehozása…", + "createNamed": "„{{name}}” létrehozása", "create": "Lista létrehozása", "none": "Még nincs lista.", "deleteConfirm": "Törlöd a(z) „{{title}}” listát?", @@ -282,9 +313,13 @@ "label": "Nézet", "browse": "Böngészés" }, + "browse": { + "items": "{{count}} elem" + }, "discover": { "tab": "Felfedezés", "continueWatching": "Folytatás", + "recentlyWatched": "Nemrég megnézve", "recentlyAdded": "Nemrég hozzáadva", "surprise": "Lepj meg", "reshuffle": "Újrakeverés", diff --git a/frontend/src/i18n/locales/hu/views.json b/frontend/src/i18n/locales/hu/views.json index 8ea38330..2328496d 100644 --- a/frontend/src/i18n/locales/hu/views.json +++ b/frontend/src/i18n/locales/hu/views.json @@ -3,7 +3,7 @@ "namePlaceholder": "Nézet neve", "saveAction": "Mentés", "cancel": "Mégse", - "empty": "Még nincs mentett nézet. Szűrd a feedet, majd mentsd el.", + "empty": "Még nincs mentett nézet. Állíts be szűrőket, majd mentsd el.", "apply": "Nézet alkalmazása", "makeDefault": "Beállítás alapértelmezettként (indításkor betölt)", "isDefault": "Alapértelmezett nézet — indításkor betölt", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a050b06f..aa7b6b2a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -51,7 +51,13 @@ import { adminApi } from "./api/admin"; export type * from "./api/admin"; import { plexApi } from "./api/plex"; export type * from "./api/plex"; -export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex"; +export { + EMPTY_PLEX_FILTERS, + plexFilterCount, + coercePlexFilters, + plexFiltersSignature, + isIntrinsicReadOnly, +} from "./api/plex"; import { messagingApi } from "./api/messaging"; export type * from "./api/messaging"; import { downloadsApi } from "./api/downloads"; diff --git a/frontend/src/lib/api/plex.dom.test.ts b/frontend/src/lib/api/plex.dom.test.ts new file mode 100644 index 00000000..d54ea341 --- /dev/null +++ b/frontend/src/lib/api/plex.dom.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import { describe, it, expect } from "vitest"; +import { + coercePlexFilters, + plexFiltersSignature, + EMPTY_PLEX_FILTERS, + type PlexFilters, +} from "./plex"; + +describe("coercePlexFilters", () => { + it("migrates the legacy single collection/collectionTitle into the multi-select shape", () => { + const out = coercePlexFilters({ collection: "A", collectionTitle: "Marvel" }); + expect(out.collections).toEqual(["A"]); + expect(out.collectionTitles).toEqual({ A: "Marvel" }); + }); + + it("migrates a legacy collection with no title", () => { + const out = coercePlexFilters({ collection: "A" }); + expect(out.collections).toEqual(["A"]); + expect(out.collectionTitles).toBeUndefined(); + }); + + it("leaves an already-multi collections blob untouched (guard on !collections.length)", () => { + const out = coercePlexFilters({ collections: ["X", "Y"], collection: "A" }); + expect(out.collections).toEqual(["X", "Y"]); + }); + + it("fills every default for an empty blob", () => { + expect(coercePlexFilters({})).toEqual(EMPTY_PLEX_FILTERS); + expect(coercePlexFilters({}).collections).toEqual([]); + }); + + it("drops the legacy sortDir field", () => { + const out = coercePlexFilters({ sortDir: "desc", genres: ["Drama"] }); + expect((out as unknown as Record).sortDir).toBeUndefined(); + expect(out.genres).toEqual(["Drama"]); + }); +}); + +describe("plexFiltersSignature", () => { + const base = (): PlexFilters => ({ + ...EMPTY_PLEX_FILTERS, + genres: ["Drama"], + collections: ["A"], + }); + + it("is independent of object key insertion order", () => { + // Full literals with genuinely different key INSERTION order (NOT spread from a shared base, which + // would inherit that base's order and make this tautological). appendPlexFilters reads by field, so + // the signatures must match — this test FAILS against an order-sensitive JSON.stringify signature. + const a: PlexFilters = { + genres: ["Drama"], + contentRatings: [], + directors: [], + actors: [], + studios: [], + keywords: [], + collections: ["A"], + }; + const b: PlexFilters = { + collections: ["A"], + keywords: [], + studios: [], + actors: [], + directors: [], + contentRatings: [], + genres: ["Drama"], + }; + expect(JSON.stringify(Object.keys(a))).not.toBe(JSON.stringify(Object.keys(b))); // orders truly differ + expect(plexFiltersSignature(a)).toBe(plexFiltersSignature(b)); + }); + + it("ignores display-only collectionTitles (not part of the wire params)", () => { + const a = base(); + const b: PlexFilters = { ...base(), collectionTitles: { A: "Marvel" } }; + expect(plexFiltersSignature(a)).toBe(plexFiltersSignature(b)); + }); + + it("differs when a real filter differs", () => { + const a = base(); + const b: PlexFilters = { ...base(), genres: ["Comedy"] }; + expect(plexFiltersSignature(a)).not.toBe(plexFiltersSignature(b)); + }); +}); diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 5812722d..44156b70 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -184,6 +184,21 @@ export interface PlexCollection { source?: string; editable: boolean; can_edit?: boolean; // effective: admin-marked editable AND a plain (non-smart/non-auto) collection + art?: string[]; // up to 2 poster rating_keys for the browse-tile montage (browse union only) + total_runtime_s?: number; // total runtime of all items, for the tile sub-line (browse union only) +} +/** A collection Plex regenerates outside a user's control — a smart collection or an external auto-list + * (imdb/tmdb/tvdb/trakt/…) — can never be edited. The single source of truth for the lock badge shown + * on both the browse tile and the info page, so the two surfaces can't drift. */ +export function isIntrinsicReadOnly(c: Pick): boolean { + return !!c.smart || (!!c.source && c.source !== "collection"); +} +// The Collection info page: the card fields + hero aggregates + the ordered members. +export interface PlexCollectionDetail extends PlexCollection { + content_rating?: string | null; + year_min?: number | null; + year_max?: number | null; + items: PlexCard[]; } export interface PlexPlaylist { id: number; @@ -239,8 +254,9 @@ export interface PlexFilters { actorMode?: "any" | "all"; // how multiple actors combine (default any = OR) studios: string[]; // OR — from any selected studio keywords: string[]; // AND — TMDB keyword chips from the detail page (E5 S5) - collection?: string | null; // a single Plex collection (rating_key) - collectionTitle?: string | null; // its title, for the active-filter chip + collections: string[]; // Plex collection rating_keys; a title can be in several + collectionMode?: "any" | "all"; // how multiple collections combine (default any = OR) + collectionTitles?: Record; // rating_key → title, for the active-filter chips } export const EMPTY_PLEX_FILTERS: PlexFilters = { genres: [], @@ -249,7 +265,39 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = { actors: [], studios: [], keywords: [], + collections: [], }; +// Coerce an opaque persisted/saved filter blob to a full PlexFilters: fill defaults for any missing +// array/scalar, and migrate the legacy single `collection`/`collectionTitle` into the multi-select +// `collections`/`collectionTitles`. Shared by PlexProvider (localStorage seed) and the saved-views +// provider (a DB view blob). Not a migration — an unknown field a newer build dropped just falls away. +export function coercePlexFilters(raw: Record): PlexFilters { + const { + sortDir: _drop, + collection, + collectionTitle, + ...rest + } = raw as Record & { + collection?: string | null; + collectionTitle?: string | null; + }; + const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...(rest as Partial) }; + if (collection && !merged.collections.length) { + merged.collections = [collection]; + if (collectionTitle) merged.collectionTitles = { [collection]: collectionTitle }; + } + return merged; +} + +// A stable signature of a PlexFilters set (only non-default keys; display-only titles excluded), so a +// saved view can be matched against the live filters regardless of key order — the Plex analogue of the +// feed's filtersToParams. Reuses appendPlexFilters so the signature can't drift from the wire params. +export function plexFiltersSignature(f: PlexFilters): string { + const u = new URLSearchParams(); + appendPlexFilters(u, f); + return u.toString(); +} + export function plexFilterCount(f: PlexFilters): number { return ( f.genres.length + @@ -262,7 +310,7 @@ export function plexFilterCount(f: PlexFilters): number { f.actors.length + f.studios.length + f.keywords.length + - (f.collection ? 1 : 0) + f.collections.length ); } // Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid @@ -283,7 +331,8 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void { if (f.actorMode === "all") u.set("actor_mode", "all"); if (f.studios?.length) u.set("studios", f.studios.join(",")); if (f.keywords?.length) u.set("keywords", f.keywords.join(",")); - if (f.collection) u.set("collection", f.collection); + if (f.collections?.length) u.set("collections", f.collections.join(",")); + if (f.collectionMode === "all") u.set("collection_mode", "all"); } export interface PlexFacets { genres: { value: string; count: number }[]; @@ -307,8 +356,10 @@ export interface PlexPlaySession { export interface PlexDiscover { scope: string; continue_watching: PlexCard[]; + recently_watched: PlexCard[]; // last fully-watched movies + episodes, newest first recently_added: PlexCard[]; - genres: { value: string; count: number }[]; // top genres → tap a tile to filter the grid + // top genres → tap a tile to filter the grid; `art` = up to 4 poster rating_keys for the collage + genres: { value: string; count: number; art: string[] }[]; because_you_watched: { anchor: { id: string; title: string }; items: PlexCard[] } | null; } @@ -379,6 +430,9 @@ export const plexApi = { const qs = u.toString(); return req(`/api/plex/collections${qs ? `?${qs}` : ""}`); }, + // One collection's hero + ordered members (the Collection info page). + plexCollection: (rk: string): Promise => + req(`/api/plex/collections/${encodeURIComponent(rk)}`), // --- Collection editing (admin only; writes back to Plex) --- plexCreateCollection: ( library: string, @@ -460,16 +514,26 @@ export const plexApi = { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }), }), - // Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary. - plexDiscover: (scope: string, lang?: string): Promise => { + // Discover landing rails in one request (serve-from-DB). `lang` picks the genre-tile vocabulary; the + // active sidebar `filters` narrow every rail (so Discover behaves like the Browse grid). + plexDiscover: (scope: string, lang?: string, filters?: PlexFilters): Promise => { const u = new URLSearchParams({ scope }); if (lang === "hu") u.set("lang", "hu"); + if (filters) appendPlexFilters(u, filters); return req(`/api/plex/discover?${u.toString()}`); }, - // A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch). - plexSurprise: (scope: string, limit?: number): Promise<{ scope: string; items: PlexCard[] }> => { + // A fresh random sample for the Surprise-me rail — a new draw every call (the ↻ refetch), narrowed by + // the active filters like the other rails. + plexSurprise: ( + scope: string, + limit?: number, + filters?: PlexFilters, + lang?: string, + ): Promise<{ scope: string; items: PlexCard[] }> => { const u = new URLSearchParams({ scope }); if (limit) u.set("limit", String(limit)); + if (lang === "hu") u.set("lang", "hu"); + if (filters) appendPlexFilters(u, filters); return req(`/api/plex/surprise?${u.toString()}`); }, plexShow: (id: string, lang?: string): Promise => diff --git a/frontend/src/lib/api/savedViews.ts b/frontend/src/lib/api/savedViews.ts index cb57ff50..d50b1eb2 100644 --- a/frontend/src/lib/api/savedViews.ts +++ b/frontend/src/lib/api/savedViews.ts @@ -1,24 +1,36 @@ -// Saved "smart views": per-user named snapshots of FeedFilters (see SavedViewsWidget). -// Only touches core's req; the FeedFilters shape is owned by feed.ts. +// Saved "smart views": per-user named snapshots of a module's filters, scoped by `source` +// ("feed" | "plex"). Only touches core's req; `filters` is an opaque JSON blob (each module casts it +// to its own filter shape), so this stays independent of feed.ts / plex.ts. import { req } from "./core"; -import type { FeedFilters } from "./feed"; -// A saved "smart view": a per-user named snapshot of FeedFilters (see SavedViewsWidget). +// A module's saved filter snapshot. `filters` is opaque here — the feed reads it as FeedFilters, Plex +// as PlexFilters; `source` is implicit (the caller queries per source). export interface SavedView { id: number; name: string; - filters: FeedFilters; + filters: Record; position: number; is_default: boolean; } +// The module a view belongs to. "feed" is the default so existing callers keep working. +type ViewSource = "feed" | "plex"; + export const savedViewsApi = { - savedViews: (): Promise => req("/api/saved-views"), - createView: (name: string, filters: FeedFilters): Promise => - req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }), + savedViews: (source: ViewSource = "feed"): Promise => + req(`/api/saved-views?source=${source}`), + createView: ( + name: string, + filters: Record, + source: ViewSource = "feed", + ): Promise => + req("/api/saved-views", { + method: "POST", + body: JSON.stringify({ name, filters, source }), + }), updateView: ( id: number, - patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>, + patch: Partial<{ name: string; filters: Record; is_default: boolean }>, ): Promise => req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), deleteView: (id: number): Promise<{ deleted: number }> => diff --git a/frontend/src/lib/history.nav.dom.test.ts b/frontend/src/lib/history.nav.dom.test.ts new file mode 100644 index 00000000..57cc863c --- /dev/null +++ b/frontend/src/lib/history.nav.dom.test.ts @@ -0,0 +1,66 @@ +// @vitest-environment jsdom +// +// The per-history-entry scroll-id helpers + nav bus that back PageScroller's scroll restoration: +// a fresh id per new view (so a forward navigation opens at the top), a stable id per entry (so Back +// restores it), and a bus so a sub-view open that doesn't re-render the scroller still triggers it. +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { nextNavId, ensureNavId, peekNavId, subscribeNav, notifyNav } from "./history"; + +beforeEach(() => { + window.history.replaceState(null, ""); +}); + +describe("nav scroll ids", () => { + it("nextNavId hands out strictly increasing ids", () => { + const a = nextNavId(); + const b = nextNavId(); + expect(b).toBeGreaterThan(a); + }); + + it("ensureNavId assigns an id to a stateless entry, then is idempotent", () => { + expect(peekNavId()).toBe(0); // no id yet + const id = ensureNavId(); + expect(id).toBeGreaterThan(0); + expect(peekNavId()).toBe(id); + expect(ensureNavId()).toBe(id); // does not reassign + expect((window.history.state as { sfScroll?: number }).sfScroll).toBe(id); + }); + + it("ensureNavId preserves other history.state keys", () => { + window.history.replaceState({ sfPage: "plex" }, ""); + ensureNavId(); + expect((window.history.state as { sfPage?: string }).sfPage).toBe("plex"); + }); + + it("a pushed entry keeps its own id (so Back restores the right one)", () => { + const first = ensureNavId(); + window.history.pushState({ sfScroll: nextNavId() }, ""); + const second = peekNavId(); + expect(second).not.toBe(first); + window.history.back(); + // jsdom doesn't truly pop synchronously, but the ids are distinct per entry — the invariant we need. + expect(second).toBeGreaterThan(first); + }); + + it("ensureNavId advances the counter past a higher persisted id (post-reload collision guard)", () => { + // After a full reload the module counter resets to 0 while sfScroll ids persist in history.state. + // Landing on an entry whose id is above the counter must push the counter past it, so the next + // minted id can't duplicate a live back-stack entry and mis-restore its scroll. + const high = 999999; + window.history.replaceState({ sfScroll: high }, ""); + expect(ensureNavId()).toBe(high); // returns the persisted id, doesn't reassign + expect(nextNavId()).toBeGreaterThan(high); // and the next mint clears it + }); + + it("notifyNav fires subscribers until they unsubscribe", () => { + const cb = vi.fn(); + const unsub = subscribeNav(cb); + notifyNav(); + expect(cb).toHaveBeenCalledTimes(1); + notifyNav(); + expect(cb).toHaveBeenCalledTimes(2); + unsub(); + notifyNav(); + expect(cb).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/lib/history.ts b/frontend/src/lib/history.ts index 7bb6f2b7..1134c58d 100644 --- a/frontend/src/lib/history.ts +++ b/frontend/src/lib/history.ts @@ -12,6 +12,58 @@ import { useEffect, useRef, useState } from "react"; import { urlWithParams } from "./urlState"; +// --- Per-history-entry scroll id + a nav bus (for scroll restoration) ------------------------ +// Every navigation entry carries a stable numeric id in history.state.sfScroll. PageScroller +// remembers scroll offsets per (entry id + view key) so Back restores exactly where the user left +// an entry, while a forward navigation (a new id) starts at the top. We drive scroll ourselves, so +// the browser's own window-scroll restoration (which can't see our inner
scroller) is off. +if (typeof window !== "undefined" && "scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual"; +} +let navCounter = 0; +// sfScroll ids are persisted into window.history.state and survive a full page reload, but this +// module-level counter resets to 0 on every load. Seed it past the current entry's id so freshly +// minted ids can't collide with ids already carried by pre-reload back-stack entries (ids are +// assigned in forward order, so the current entry's id is ≥ every ancestor's) — a collision would +// otherwise cross-contaminate PageScroller's per-entry scroll offsets after a reload + navigation. +if (typeof window !== "undefined") { + const seed = (window.history.state as { sfScroll?: number } | null)?.sfScroll; + if (typeof seed === "number") navCounter = seed; +} +/** A fresh entry id — stamp it into the state of every pushState that opens a new main-content view. */ +export function nextNavId(): number { + return ++navCounter; +} +/** The current entry's scroll id, assigning one (via replaceState) to entries that predate this — the + * initial load, or an entry pushed without one — so the id survives Back/Forward. */ +export function ensureNavId(): number { + const st = (window.history.state ?? {}) as { sfScroll?: number }; + if (typeof st.sfScroll === "number") { + // Keep the counter ahead of any id we observe (e.g. a Back landed on a higher-id entry after a + // reload seeded us low) so the next mint never duplicates a live entry's id. + if (st.sfScroll > navCounter) navCounter = st.sfScroll; + return st.sfScroll; + } + const id = nextNavId(); + window.history.replaceState({ ...st, sfScroll: id }, ""); + return id; +} +/** Read the current entry's scroll id without assigning one (0 if none). */ +export function peekNavId(): number { + const st = window.history.state as { sfScroll?: number } | null; + return typeof st?.sfScroll === "number" ? st.sfScroll : 0; +} +const navListeners = new Set<() => void>(); +/** Fire after a pushState that opens a new view but does NOT re-render PageScroller (a Plex sub-view + * open): lets the scroller run its restore/reset without waiting on a React render it won't get. */ +export function notifyNav(): void { + navListeners.forEach((cb) => cb()); +} +export function subscribeNav(cb: () => void): () => void { + navListeners.add(cb); + return () => navListeners.delete(cb); +} + // --- Sub-views (value carried in history.state._sub) --------------------------------------- /** Optional address-bar mirroring for a sub-view, so a deep sub-view (e.g. a Plex show page) is * bookmarkable. history.state._sub stays the source of truth for Back/Forward; this only adds the URL. */ @@ -61,7 +113,14 @@ export function useHistorySubview( open: (next: T) => { setView(next); const href = url ? urlWithParams({ [url.key]: url.encode(next) }) : ""; - window.history.pushState({ ...window.history.state, _sub: next }, "", href); + // A fresh sfScroll so the opened sub-view is its own scroll entry (starts at the top, and Back + // restores the view we came from) — then notify PageScroller, which this push doesn't re-render. + window.history.pushState( + { ...window.history.state, _sub: next, sfScroll: nextNavId() }, + "", + href, + ); + notifyNav(); }, back: () => window.history.back(), }; diff --git a/frontend/src/lib/hueTile.ts b/frontend/src/lib/hueTile.ts new file mode 100644 index 00000000..2bd14dee --- /dev/null +++ b/frontend/src/lib/hueTile.ts @@ -0,0 +1,13 @@ +// A stable, pleasant hue per string (genre/playlist/collection title) plus the matching tile gradient. +// Shared by the Discover genre tiles (PlexDiscover) and the header Playlist/Collection browse tiles +// (plexBrowseLists) so the two surfaces that are meant to look identical can't drift. Hashing keeps a +// name the same color across renders/sessions without storing anything. +export function hueOf(name: string): number { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360; + return h; +} + +export function hueTileGradient(hue: number): string { + return `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`; +} diff --git a/frontend/src/lib/queryKeys.test.ts b/frontend/src/lib/queryKeys.test.ts index e48902a1..88b035d2 100644 --- a/frontend/src/lib/queryKeys.test.ts +++ b/frontend/src/lib/queryKeys.test.ts @@ -84,4 +84,24 @@ describe("qk — variadic composite plex keys", () => { { genres: [] }, ]); }); + + it("preserves scope+lang+filters as distinct Discover/Surprise key segments", () => { + // Shape-pinning only: these lock that the variadic factory carries every arg through (a "tidy-up" + // that drops a param would break cache separation). That the CALL SITE actually passes lang is + // guarded separately in PlexDiscover.dom.test.tsx (it inspects the registered query keys). + expect(qk.plexDiscover("movie", "hu", FILTERS)).toEqual([ + "plex-discover", + "movie", + "hu", + FILTERS, + ]); + expect(qk.plexSurprise("movie", "hu", FILTERS)).toEqual([ + "plex-surprise", + "movie", + "hu", + FILTERS, + ]); + // A bare call stays a prefix for the reshuffle/watched-toggle invalidations. + expect(qk.plexSurprise()).toEqual(["plex-surprise"]); + }); }); diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 08d69d50..356d5910 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -44,7 +44,9 @@ export const qk = { playlist: (id?: number | null) => id !== undefined ? (["playlist", id] as const) : (["playlist"] as const), playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const, - savedViews: () => ["saved-views"] as const, + // Keyed by source ("feed" | "plex") so each module's views cache independently; `savedViews()` (no + // arg) stays a prefix for a cross-source invalidation. + savedViews: (...rest: unknown[]) => ["saved-views", ...rest], // --- account / misc (R7 S2b) --- (me/myStatus/tags already declared as shared roots above) accounts: () => ["accounts"] as const, @@ -97,8 +99,10 @@ export const qk = { // both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle // refetches it alone. plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest], - plexSurprise: (scope?: string) => - scope !== undefined ? (["plex-surprise", scope] as const) : (["plex-surprise"] as const), + // Variadic like plexDiscover so it can key by scope + lang + the active filters (lang because a genre + // filter narrows against the language-specific genre column, like the grid); `plexSurprise()` (no args) + // stays a prefix for the reshuffle/watched-toggle invalidations. + plexSurprise: (...rest: unknown[]) => ["plex-surprise", ...rest], plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest], // Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN // metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both. diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 8d5b47f0..71122d33 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,25 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.72.0", + date: "2026-08-23", + summary: + "A big Plex browsing overhaul: a cleaner filter bar, a full Collection page you can edit straight through to Plex, poster-collage tiles, new Discover rows, and scroll position remembered as you move around.", + features: [ + "The Plex filter bar now matches the feed: watch-state (All / Unwatched / …) moved out of the sidebar into a toolbar pill, the collection filter is multi-select with an Any/All switch, and the playlists picker is a compact search box. Your active filters show as removable chips.", + "The Discover ↔ Browse switch and new Playlist / Collection browse modes live in the header pill now, so switching how you look at Plex is one tap from anywhere.", + "Discover rows now honour your sidebar filters — narrow to a genre or a collection and the rails update to match, instead of jumping you to the full grid.", + "New Collection page: open any collection for a proper hero (artwork, year range, certificate, total runtime, summary), Play-all or Shuffle, and — for your own manual collections — rename, add or remove titles, delete, and create a brand-new collection. Every edit writes straight through to Plex; smart and external (IMDb/TMDb/…) lists are clearly marked read-only.", + "Browse-by-genre and collection tiles now show a poster collage of what's inside, falling back to a colour when there's no artwork yet.", + "A new “Recently watched” row on Discover, and “Surprise me” now only suggests things you haven't seen, capped to a handful of movies and shows.", + "Your Personal Views (saved filter setups) now work on Plex too, alongside the feed.", + "Plex remembers your scroll position per page, so going Back returns you exactly where you were, while opening something new starts at the top.", + ], + chores: [ + "The whole batch went through two multi-agent deep-review rounds (findings fixed each round, then re-reviewed until clean).", + ], + }, { version: "0.71.0", date: "2026-08-21", diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0b39bfb1..f0717f1c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,6 +9,7 @@ import { FeedFiltersProvider } from "./components/FeedFiltersProvider"; import { SavedViewsProvider } from "./components/SavedViewsProvider"; import { PlaylistsProvider } from "./components/PlaylistsProvider"; import { PlexProvider } from "./components/PlexProvider"; +import { PlexViewBridge } from "./components/PlexViewBridge"; import { ChannelsProvider } from "./components/ChannelsProvider"; import { FeedViewProvider } from "./components/FeedViewProvider"; import { PrefsProvider } from "./components/PrefsProvider"; @@ -58,21 +59,25 @@ const root = - - - - - - - - - - - - - - - + + + + {/* SavedViewsProvider sits below BOTH filter providers so it can switch its + source (feed ⟷ plex) by the active page and read the right live filters. */} + + + + + + + + + + + + + +