release: 0.72.0 (feature/plex-filter-bar-overhaul)
gate / frontend (push) Failing after 41s
gate / backend (push) Successful in 2m6s

This commit is contained in:
2026-08-23 07:27:18 +02:00
50 changed files with 2962 additions and 390 deletions
+1 -1
View File
@@ -1 +1 @@
0.71.0
0.72.0
@@ -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")
+9 -5
View File
@@ -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")
+61 -5
View File
@@ -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,
}
+146 -9
View File
@@ -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)
+176 -51
View File
@@ -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`.
+31 -9
View File
@@ -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
@@ -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
+65
View File
@@ -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"]
+75
View File
@@ -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()
+5 -1
View File
@@ -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"
/>
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
</div>
+4 -1
View File
@@ -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" && <FeedScopeToggle />}
{/* 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 && <PlexScopeToggle />}
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
{/* 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
@@ -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],
);
+54 -12
View File
@@ -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<string, number>();
// 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<string | null>(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 (
<main
+27 -7
View File
@@ -16,10 +16,11 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { SavedView } from "../lib/api";
import { coercePlexFilters, type FeedFilters, type SavedView } from "../lib/api";
import Modal from "./Modal";
import { useSavedViews, asFilters } from "./SavedViewsProvider";
import { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name,
@@ -72,19 +73,38 @@ function ViewRow({
export default function PersonalViewsModal() {
const { t } = useTranslation();
const { views, activeViewId, apply, create, rename, toggleDefault, remove, reorder, closeModal } =
useSavedViews();
const {
source,
views,
activeViewId,
apply,
create,
rename,
toggleDefault,
remove,
reorder,
closeModal,
} = useSavedViews();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const [selectedId, setSelectedId] = useState<number | null>(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;
+113 -35
View File
@@ -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<Sub>({ 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<PlexFilters>) {
const merged = { ...filters } as unknown as Record<string, unknown>;
const isPlainObject = (x: unknown): x is Record<string, unknown> =>
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() {
</Suspense>
);
}
if (sub.view.kind === "collection") {
const cid = sub.view.id;
return (
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
<PlexCollectionView
collectionId={cid}
onBack={sub.back}
onPlay={(rk, queue) => sub.open({ kind: "player", id: rk, queue })}
onOpenCard={onCard}
onInfoCard={onInfo}
/>
</Suspense>
);
}
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 = (
<SegmentedToggle
size="md"
ariaLabel={t("plex.view.label")}
options={[
{ value: "discover", label: t("plex.discover.tab") },
{ value: "grid", label: t("plex.view.browse") },
]}
value={sub.view.kind === "discover" ? "discover" : "grid"}
onChange={(v) => 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 = (
<SortControl
options={sortOptions}
sort={sortState}
onChange={(next) => 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 (
<div className="p-4 max-w-[1600px] mx-auto">
<div className="mb-4 flex items-center">{viewToggle}</div>
<PlexDiscover
scope={scope}
onOpenCard={onCard}
@@ -383,31 +435,57 @@ export default function PlexBrowse() {
</div>
);
}
// 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 (
<div className="p-4 max-w-[1600px] mx-auto">
<PlexPlaylistsBrowse onOpen={(id) => sub.open({ kind: "playlist", id })} />
</div>
);
}
if (sub.view.kind === "collections") {
return (
<div className="p-4 max-w-[1600px] mx-auto">
<PlexCollectionsBrowse onPick={(id) => sub.open({ kind: "collection", id })} />
</div>
);
}
return (
<div className="p-4 max-w-[1600px] mx-auto">
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */}
<PageToolbar>
<div className="px-4 pt-2 pb-1 max-w-[1600px] mx-auto flex items-center gap-1.5">
<ItemCount count={browseQ.isLoading ? undefined : total} />
{viewToggle}
{/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
<div className="ml-auto flex items-center gap-1.5">
<SortControl
options={sortOptions}
sort={sortState}
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
label={t("plex.filter.sort")}
ascLabel={t("plex.filter.dir.asc")}
descLabel={t("plex.filter.dir.desc")}
<div className="px-4 pt-2 pb-1 max-w-[1600px] mx-auto">
<div className="flex items-center gap-1.5">
<ItemCount count={browseQ.isLoading ? undefined : total} />
{/* 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. */}
<FilterSelect
label={t("plex.filter.show")}
testId="plex-show"
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
selected={[show]}
onChange={(next) => setShow(next[0] ?? "all")}
/>
</div>
{/* 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. */}
<div className="mt-1 flex items-center gap-2">
{/* The active personal view (if any) leads the applied-filter row, like the feed. */}
<SelectedViewChip />
<ActiveFilterChips
filters={plexChips}
onClearAll={clearAllFilters}
className="flex-1"
/>
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
</div>
</PageToolbar>
<ActiveFilterChips filters={plexChips} onClearAll={clearAllFilters} />
{personCards.length > 0 && (
<div className="mb-4 flex flex-wrap gap-3">
{personCards.map((p) => (
@@ -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);
}
@@ -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<PlexCollectionDetail>(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 (
<div className="glass-media mx-auto max-w-[1200px] p-4">
<BackBtn onBack={onBack} label={t("plex.backToLibrary")} />
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d ? (
<LoadingState label={t("plex.loading")} />
) : (
<>
<DetailHero
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
posterTitle={t("plex.play")}
posterDisabled={playable.length === 0}
showPlayOverlay={playable.length > 0}
onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)}
customizeMenu={null}
>
{renaming ? (
<input
autoFocus
value={name}
onChange={(e) => 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"
/>
) : (
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold leading-tight">{d.title}</h1>
{readOnly && (
<span
className="inline-flex items-center gap-1 rounded-full bg-surface px-2 py-0.5 text-2xs text-muted"
title={t("plex.collection.readOnly")}
>
<Lock className="h-3 w-3" />
{t("plex.collection.readOnly")}
</span>
)}
</div>
)}
{meta.length > 0 && (
<div className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted">
{meta.map((m, i) => (
<span key={i}>
{i > 0 && "· "}
{m}
</span>
))}
</div>
)}
{d.summary && (
<div className="mt-3">
<p
className={`text-sm leading-relaxed text-muted ${expanded ? "" : "line-clamp-3"}`}
>
{d.summary}
</p>
<button
onClick={() => setExpanded((v) => !v)}
className="mt-1 text-xs font-medium text-accent hover:underline"
>
{expanded ? t("plex.collection.less") : t("plex.collection.more")}
</button>
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
{playable.length > 0 && (
<>
<ActionBtn
primary
onClick={() => onPlay(playable[0]!, playable)}
icon={Play}
label={t("plex.collection.playAll")}
/>
<ActionBtn
onClick={() => {
const order = shuffled(playable);
onPlay(order[0]!, order);
}}
icon={Shuffle}
label={t("plex.collection.shuffle")}
/>
</>
)}
{canEdit && (
<>
<ActionBtn
onClick={() => {
setName(d.title);
setRenaming(true);
}}
icon={Pencil}
label={t("plex.collection.rename")}
/>
<ActionBtn
onClick={() => setAdding((v) => !v)}
icon={Plus}
label={t("plex.collection.addItem")}
/>
<ActionBtn
onClick={doDelete}
icon={Trash2}
label={t("plex.collection.delete")}
disabled={busy}
/>
</>
)}
{canEnableEdit && (
<ActionBtn
onClick={enableEdit}
icon={Check}
label={t("plex.collection.enableEdit")}
/>
)}
</div>
</DetailHero>
{adding && canEdit && (
<AddItemPanel
collectionId={collectionId}
scope={items.some((c) => c.type === "show") ? "show" : "movie"}
existing={new Set(items.map((c) => c.id))}
onAdded={invalidate}
onClose={() => setAdding(false)}
/>
)}
{items.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.empty")}</p>
) : (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
{items.map((c) => (
<div key={c.id} className="relative">
<PlexPosterCard
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggleWatched}
/>
{canEdit && (
<button
onClick={() => doRemove(c)}
disabled={busy}
title={t("plex.collection.removeItem")}
aria-label={t("plex.collection.removeItem")}
className="glass-card glass-hover absolute right-1.5 top-1.5 z-base grid h-6 w-6 place-items-center rounded-full text-fg disabled:opacity-50"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}
// 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<string>;
onAdded: () => void;
onClose: () => void;
}) {
const { t } = useTranslation();
const [term, setTerm] = useState("");
const debounced = useDebounced(term.trim(), 300);
const [added, setAdded] = useState<Set<string>>(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 (
<div className="glass-card mb-6 rounded-2xl p-4">
<div className="mb-3 flex items-center gap-2">
<Search className="h-4 w-4 text-muted" />
<input
autoFocus
value={term}
onChange={(e) => 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"
/>
<button
onClick={onClose}
aria-label={t("common.close")}
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
>
<X className="h-4 w-4" />
</button>
</div>
{debounced.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>
) : q.isLoading ? (
<LoadingState label={t("plex.loading")} />
) : q.isError ? (
<p className="text-muted text-sm">{t("common.loadError")}</p>
) : results.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
) : (
<ul className="flex flex-col gap-1">
{results.map((c) => {
const inColl = existing.has(c.id) || added.has(c.id);
return (
<li key={c.id} className="flex items-center gap-3">
<img
src={c.thumb}
alt=""
className="h-12 w-8 shrink-0 rounded object-cover"
loading="lazy"
/>
<span className="min-w-0 flex-1 truncate text-sm">
{c.title}
{c.year != null && <span className="text-muted"> · {c.year}</span>}
</span>
<button
onClick={() => add(c)}
disabled={inColl}
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
>
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -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(
<QueryClientProvider client={qc}>
<PlexDiscover
scope="both"
onOpenCard={noop}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
/>
</QueryClientProvider>,
);
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,
+32 -32
View File
@@ -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<PlexDiscoverData>(qk.plexDiscover(scope, lang), (old) =>
qc.setQueryData<PlexDiscoverData>(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 && (
<CardRail
title={t("plex.discover.recentlyWatched")}
ariaLabel={t("plex.discover.recentlyWatched")}
cards={d.recently_watched}
{...railProps}
/>
)}
{d.recently_added.length > 0 && (
<CardRail
title={t("plex.discover.recentlyAdded")}
@@ -201,23 +209,15 @@ export default function PlexDiscover({
<section className="mb-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g) => {
const hue = genreHue(g.value);
return (
<button
key={g.value}
type="button"
onClick={() => onGenre(g.value)}
className="glass-hover relative flex h-20 items-end overflow-hidden rounded-xl p-3 text-left transition"
style={{
background: `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`,
}}
>
<span className="text-sm font-semibold text-white drop-shadow">{g.value}</span>
<span className="absolute right-2 top-2 text-2xs text-white/70">{g.count}</span>
</button>
);
})}
{d.genres.map((g) => (
<PosterTile
key={g.value}
title={g.value}
art={g.art}
onClick={() => onGenre(g.value)}
className="h-24"
/>
))}
</div>
</section>
)}
+6 -1
View File
@@ -693,7 +693,12 @@ export default function PlexInfo({
</h2>
{onFilter && (
<button
onClick={() => onFilter({ collection: col.id, collectionTitle: col.title })}
onClick={() =>
onFilter({
collections: [col.id],
collectionTitles: { [col.id]: col.title },
})
}
className="shrink-0 text-xs text-muted hover:text-accent"
>
{t("plex.info.browseCollection")}
+6 -5
View File
@@ -1,5 +1,5 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { parseSortState, type SortState } from "../lib/columnSort";
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
@@ -122,10 +122,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
const [filtersRaw, setFiltersRaw] = useState<string>(readPlexFiltersRaw);
const filters = useMemo<PlexFilters>(() => {
try {
// Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak
// into the facets query key and be re-persisted on every filter change.
const { sortDir: _drop, ...rest } = JSON.parse(filtersRaw);
return { ...EMPTY_PLEX_FILTERS, ...rest };
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
// SortState — left here it would leak into the facets key and be re-persisted), and migrates the
// legacy single `collection`/`collectionTitle` into `collections`/`collectionTitles`. Shared with
// the saved-views provider so the localStorage seed and a DB view blob coerce identically.
return coercePlexFilters(JSON.parse(filtersRaw));
} catch {
return EMPTY_PLEX_FILTERS;
}
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
//
// Contract of the header library-scope toggle (S3 relocation): it renders the three scopes, marks
// the current one, and writes the picked scope back to the provider. i18n is key-passthrough and the
// provider is mocked so the test drives scope directly; SegmentedToggle is a plain button group (no
// floating-ui), so no observer stubs are needed.
// Contract of the header library-scope pill (S3 relocation + Piece 2 browse modes): it renders the
// three library scopes PLUS the Playlist/Collection browse-mode entries, marks the active one, writes a
// picked scope to the provider, and drives the playlists/collections views through the PlexViewBridge.
// i18n is key-passthrough and both the provider and the bridge are mocked; SegmentedToggle is a plain
// button group (no floating-ui), so no observer stubs are needed.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
@@ -19,32 +20,70 @@ vi.mock("./PlexProvider", () => ({
usePlexActions: () => ({ setScope }),
}));
// The bridge: a controllable current view kind + an open spy.
const controls = { kind: "discover" as string, open: vi.fn() };
vi.mock("./PlexViewBridge", () => ({
usePlexViewControls: () => controls,
}));
import PlexScopeToggle from "./PlexScopeToggle";
beforeEach(() => {
plexState.scope = "both";
controls.kind = "discover";
setScope.mockReset();
controls.open.mockReset();
});
afterEach(cleanup);
const buttons = () => [
...screen.getByRole("group", { name: "plex.filter.scope" }).querySelectorAll("button"),
];
const labels = () => buttons().map((b) => b.textContent);
const pressed = () =>
buttons()
.filter((b) => b.getAttribute("aria-pressed") === "true")
.map((b) => b.textContent);
describe("PlexScopeToggle", () => {
it("renders the three library scopes, current one pressed", () => {
it("renders the three scopes plus the two browse modes, the current scope pressed", () => {
plexState.scope = "movie";
render(<PlexScopeToggle />);
const group = screen.getByRole("group", { name: "plex.filter.scope" });
const btns = [...group.querySelectorAll("button")];
expect(btns.map((b) => b.textContent)).toEqual([
expect(labels()).toEqual([
"plex.filter.scopeOpt.both",
"plex.filter.scopeOpt.movie",
"plex.filter.scopeOpt.show",
"plex.filter.scopeOpt.playlists",
"plex.filter.scopeOpt.collections",
]);
const pressed = btns.filter((b) => b.getAttribute("aria-pressed") === "true");
expect(pressed.map((b) => b.textContent)).toEqual(["plex.filter.scopeOpt.movie"]);
expect(pressed()).toEqual(["plex.filter.scopeOpt.movie"]);
});
it("writes the picked scope back to the provider", () => {
it("writes a picked scope back to the provider (no view switch when already on a title view)", () => {
render(<PlexScopeToggle />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
expect(setScope).toHaveBeenCalledWith("show");
expect(controls.open).not.toHaveBeenCalled();
});
it("opens the playlists view (not a scope change) for the Playlist mode", () => {
render(<PlexScopeToggle />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.playlists" }));
expect(controls.open).toHaveBeenCalledWith({ kind: "playlists" });
expect(setScope).not.toHaveBeenCalled();
});
it("marks the Collection mode active when the current view is collections", () => {
controls.kind = "collections";
render(<PlexScopeToggle />);
expect(pressed()).toEqual(["plex.filter.scopeOpt.collections"]);
});
it("returns to the title grid when a scope is picked from a browse mode", () => {
controls.kind = "playlists";
render(<PlexScopeToggle />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.movie" }));
expect(setScope).toHaveBeenCalledWith("movie");
expect(controls.open).toHaveBeenCalledWith({ kind: "grid" });
});
});
+30 -7
View File
@@ -1,25 +1,48 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./SegmentedToggle";
import { usePlex, usePlexActions } from "./PlexProvider";
import { usePlexViewControls } from "./PlexViewBridge";
// The Plex library scope (both | movie | show) as a compact header control, sitting next to the
// search box (S3 toolbar relocation — it used to be a sidebar island). Reads/writes the same
// provider state the sidebar did, so scope stays in sync everywhere; reuses the shared
// SegmentedToggle and the existing plex.filter.scopeOpt.* labels.
// The Plex "browse target" as a compact header control next to the search box: the three library
// scopes (both | movie | show, which browse TITLES) plus two extra entry points — Playlist and
// Collection — that switch the content area to a grid of the user's playlists / of the collections.
// The scopes read/write the shared provider state (so scope stays in sync everywhere); the two extra
// modes drive PlexBrowse's sub-view through the PlexViewBridge. Picking a scope while in a
// playlists/collections surface returns to the title grid at that scope.
const SCOPES = ["both", "movie", "show"] as const;
export default function PlexScopeToggle() {
const { t } = useTranslation();
const { scope } = usePlex();
const { setScope } = usePlexActions();
const ctl = usePlexViewControls();
const inPlaylists = ctl?.kind === "playlists";
const inCollections = ctl?.kind === "collections";
// The active segment: the browse-mode when in one, else the library scope.
const value = inPlaylists ? "playlists" : inCollections ? "collections" : scope;
const onChange = (v: string) => {
if (v === "playlists" || v === "collections") {
ctl?.open({ kind: v });
return;
}
setScope(v);
// Leaving the playlists/collections surface for a title scope → show the title grid.
if (inPlaylists || inCollections) ctl?.open({ kind: "grid" });
};
return (
<div className="pointer-events-auto shrink-0">
<SegmentedToggle
size="md"
ariaLabel={t("plex.filter.scope")}
options={SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) }))}
value={scope}
onChange={setScope}
options={[
...SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) })),
{ value: "playlists", label: t("plex.filter.scopeOpt.playlists") },
{ value: "collections", label: t("plex.filter.scopeOpt.collections") },
]}
value={value}
onChange={onChange}
/>
</div>
);
+141 -107
View File
@@ -13,8 +13,11 @@ import { type PanelItem } from "./PanelGroups";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup from "./FilterChipGroup";
import Combobox, { type ComboboxOption } from "./Combobox";
import { usePlexClearFilters } from "./usePlexActiveFilters";
import SavedViewsPopover from "./SavedViewsPopover";
import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./SavedViewsProvider";
import { usePlex, usePlexActions } from "./PlexProvider";
import { useMe } from "../lib/useMe";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -28,7 +31,6 @@ type Props = {
onToggleCollapse: () => void;
};
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
const RATING_STEPS = [5, 6, 7, 8, 9];
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
@@ -67,22 +69,17 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// the header search box); this rail is the primary editor of them. Opening a playlist is a
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, filters } = usePlex();
const { setShow, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const { setFilters } = usePlexActions();
const isDemo = useMe().is_demo;
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
// title opens the full manager modal (same shared components as the feed). Hidden for the demo account.
const { openModal, modalOpen } = useSavedViews();
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "plex";
const qc = useQueryClient();
async function createPlaylist() {
const title = newPlaylist.trim();
if (!title) return;
const pl = await api.plexCreatePlaylist(title);
setNewPlaylist("");
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
onOpenPlaylist(pl.id);
}
const { collectionTitle: _ct, ...facetKey } = filters;
// Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling
// never refetches. collectionMode stays IN the key: any/all changes the result set.
const { collectionTitles: _cts, ...facetKey } = filters;
const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch
const facetsQ = useQuery({
queryKey: qk.plexFacets(scope, show, facetKey, lang),
@@ -96,10 +93,8 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only
// the filters that still live in this panel (expanded filters + watch-state).
const activeCount = fCount + (show !== "all" ? 1 : 0);
const anyActive = activeCount > 0;
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
const clearAll = usePlexClearFilters();
const durBucketKey = DURATION_BUCKETS.find(
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
@@ -115,56 +110,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
{
id: "playlists",
title: t("plex.playlist.section"),
body: (
<>
<div className="mb-1.5 flex gap-1.5">
<input
value={newPlaylist}
onChange={(e) => setNewPlaylist(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
placeholder={t("plex.playlist.newPlaceholder")}
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={createPlaylist}
disabled={!newPlaylist.trim()}
title={t("plex.playlist.create")}
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
) : (
<div className="space-y-0.5">
{playlistsQ.data!.playlists.map((p) => (
<button
key={p.id}
onClick={() => onOpenPlaylist(p.id)}
className="glass-hover flex w-full items-center gap-2 rounded-sm px-1.5 py-1 text-left text-sm"
>
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="min-w-0 flex-1 truncate">{p.title}</span>
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
</button>
))}
</div>
)}
</>
),
},
{
id: "watch",
title: t("plex.filter.show"),
body: (
<FilterChipGroup
ariaLabel={t("plex.filter.show")}
options={SHOW_OPTS.map((s) => ({ value: s, label: t(`plex.filter.showOpt.${s}`) }))}
value={show}
onChange={setShow}
/>
),
body: <PlaylistPicker />,
},
...(peopleActive
? [
@@ -205,9 +151,11 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
title: t("plex.filter.collection"),
body: (
<CollectionPicker
value={filters.collection}
title={filters.collectionTitle}
onPick={(id, title) => 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 (
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
actions={
anyActive ? (
<button
onClick={clearAll}
className="inline-flex items-center gap-1 text-xs text-muted hover:text-accent transition px-1 whitespace-nowrap"
>
<X className="w-3.5 h-3.5" />
{t("plex.filter.clearAll")}
</button>
) : undefined
<>
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
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 : (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
)
}
onTitleClick={isDemo ? undefined : openModal}
titleActionLabel={isDemo ? undefined : t("views.manageTitle")}
/>
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
{modalOpen && <PersonalViewsModal />}
</>
);
}
// 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 (
<Combobox
label={t("plex.playlist.section")}
multiple={false}
clientFilter={false} // we build `options` (incl. the create row) against the query ourselves
options={options}
selected={[]}
query={query}
onQueryChange={setQuery}
placeholder={t("plex.playlist.pickerPlaceholder")}
emptyText={t("plex.playlist.none")}
renderOption={(o) =>
o.value === CREATE_PLAYLIST ? (
<>
<Plus className="h-3.5 w-3.5 shrink-0 text-accent" />
<span className="flex-1 truncate">
{t("plex.playlist.createNamed", { name: o.label })}
</span>
</>
) : (
<>
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
<span className="flex-1 truncate">{o.label}</span>
{o.count != null && (
<span className="shrink-0 text-2xs tabular-nums text-muted">{o.count}</span>
)}
</>
)
}
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 <Combobox> 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 <Combobox>
// 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<string, string> | undefined;
mode: "any" | "all";
onChange: (values: string[], titles: Record<string, string> | 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<string, string> = { ...titles };
for (const o of options) if (o.label) labelMap[o.value] = o.label;
return (
<Combobox
label={t("plex.filter.collection")}
multiple={false}
clientFilter={false} // the server already narrows to `search`
options={options}
selected={value ? [value] : []}
selectedLabels={value && title ? { [value]: title } : undefined}
selected={values}
selectedLabels={titles}
query={search}
onQueryChange={setSearch}
onOpenChange={setOpen}
loading={q.isFetching}
placeholder={t("plex.filter.collectionSearch")}
emptyText={t("plex.filter.collectionEmpty")}
matchMode={mode === "all" ? "and" : "or"}
onMatchModeChange={(m) => 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<string, string> = {};
for (const id of next) nextTitles[id] = labelMap[id] ?? titles?.[id] ?? id;
onChange(next, next.length ? nextTitles : undefined);
}}
/>
);
@@ -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<PlexViewControls | null>(null);
const PublishCtx = createContext<(c: PlexViewControls | null) => void>(() => {});
export function PlexViewBridge({ children }: { children: ReactNode }) {
const [controls, setControls] = useState<PlexViewControls | null>(null);
return (
<PublishCtx.Provider value={setControls}>
<ReadCtx.Provider value={controls}>{children}</ReadCtx.Provider>
</PublishCtx.Provider>
);
}
export const usePlexViewControls = () => useContext(ReadCtx);
export const usePublishPlexView = () => useContext(PublishCtx);
@@ -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<typeof import("react-i18next")>();
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(<PlexViewToggle />);
// 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(<PlexViewToggle />);
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(<PlexViewToggle />);
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(<PlexViewToggle />);
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(<PlexViewToggle />);
expect(container.firstChild).toBeNull();
});
});
@@ -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 (
<SegmentedToggle
size="md"
className="pointer-events-auto shrink-0"
ariaLabel={t("plex.view.label")}
options={[
{ value: "discover", label: t("plex.discover.tab") },
{ value: "grid", label: t("plex.view.browse") },
]}
value={ctl.kind === "discover" ? "discover" : "grid"}
onChange={(v) => ctl.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
/>
);
}
+61
View File
@@ -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 (
<button
type="button"
onClick={onClick}
title={title}
className={`glass-hover group relative flex flex-col overflow-hidden rounded-xl text-left transition ${className ?? ""}`}
>
{/* Top half: 2 montage posters, or the colour hash when a group has no art. */}
<div
className="grid h-1/2 min-h-0 grid-cols-2"
aria-hidden
style={posters.length === 0 ? { background: band } : undefined}
>
{posters.length > 0 &&
[0, 1].map((i) =>
posters[i] ? (
<img
key={i}
src={`/api/plex/image/${posters[i]}`}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
) : (
<div key={i} style={{ background: hueTileGradient(hueOf(title + i)) }} />
),
)}
</div>
{/* Bottom half: solid colour band — the label lives here so it stays readable over any art. */}
<div className="relative flex flex-1 flex-col justify-end p-2.5" style={{ background: band }}>
{topRight != null && <span className="absolute right-2 top-2">{topRight}</span>}
<div className="truncate text-sm font-semibold text-white drop-shadow">{title}</div>
{subtitle != null && <div className="truncate text-2xs text-white/80">{subtitle}</div>}
</div>
</button>
);
}
+98 -39
View File
@@ -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<string, unknown>;
setFilters: (f: Record<string, unknown>) => void;
keyOf: (f: Record<string, unknown>) => string;
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
/** 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<string, unknown>) =>
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
const plexKeyOf = (f: Record<string, unknown>) => 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<boolean>;
rename: (id: number, name: string) => Promise<boolean>;
toggleDefault: (v: SavedView) => Promise<void>;
/** Delete a view after confirming; resolves true only when it was actually deleted (false on cancel/fail). */
remove: (v: SavedView) => Promise<boolean>;
reorder: (nextIds: number[]) => Promise<void>;
}
@@ -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<string, unknown>,
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
keyOf: plexKeyOf,
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
stripForSave: (f) => f, // Plex has no transient field to drop
};
}
return {
source: "feed",
filters: feedFilters as unknown as Record<string, unknown>,
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
keyOf: feedKeyOf,
asFilters: (raw) =>
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
// 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<number | null>(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<unknown>, failMsg: string): Promise<boolean> {
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<SavedView[]>(
qk.savedViews(),
qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
+275
View File
@@ -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 (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
);
}
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
if (playlists.length === 0)
return <p className="text-muted text-sm">{t("plex.playlist.none")}</p>;
return (
<div className={GRID}>
{playlists.map((p) => (
<PosterTile
key={p.id}
title={p.title}
subtitle={t("plex.browse.items", { count: p.item_count })}
topRight={<ListMusic className="h-4 w-4 text-white/80 drop-shadow" />}
onClick={() => onOpen(p.id)}
className="h-28"
/>
))}
</div>
);
}
// 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<string>;
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 (
<div className="glass-card mb-6 rounded-2xl p-4">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold">{t("plex.collection.newCollection")}</h3>
<button
onClick={onClose}
aria-label={t("common.close")}
className="glass-hover grid h-8 w-8 place-items-center rounded-lg"
>
<X className="h-4 w-4" />
</button>
</div>
<input
autoFocus
value={name}
onChange={(e) => 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 ? (
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.nameMaybeTaken")}</p>
) : (
<p className="mt-1 mb-2 text-xs text-muted">{t("plex.collection.newHint")}</p>
)}
<div className="mb-3 flex items-center gap-2">
<Search className="h-4 w-4 shrink-0 text-muted" />
<input
value={term}
onChange={(e) => 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"
/>
</div>
{!nameOk || debounced.length === 0 ? null : q.isLoading ? (
<LoadingState label={t("plex.loading")} />
) : q.isError ? (
<p className="text-muted text-sm">{t("common.loadError")}</p>
) : results.length === 0 ? (
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
) : (
<ul className="flex flex-col gap-1">
{results.map((c) => (
<li key={c.id} className="flex items-center gap-3">
<img
src={c.thumb}
alt=""
className="h-12 w-8 shrink-0 rounded object-cover"
loading="lazy"
/>
<span className="min-w-0 flex-1 truncate text-sm">
{c.title}
{c.year != null && <span className="text-muted"> · {c.year}</span>}
</span>
<button
onClick={() => create(c)}
disabled={busy}
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
>
<Check className="h-3.5 w-3.5" />
{t("plex.collection.createWith")}
</button>
</li>
))}
</ul>
)}
</div>
);
}
// 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 (
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<input
type="search"
value={search}
onChange={(e) => 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 && (
<button
onClick={() => setCreating(true)}
className="glass-card glass-hover inline-flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm"
>
<Plus className="h-4 w-4" />
{t("plex.collection.newCollection")}
</button>
)}
</div>
{creating && (
<NewCollectionPanel
existingNames={existingNames}
onCreated={(rk) => {
setCreating(false);
onPick(rk, "");
}}
onClose={() => setCreating(false)}
/>
)}
{q.isError && !q.data ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading ? (
<LoadingState label={t("plex.loading")} />
) : collections.length === 0 ? (
<p className="text-muted text-sm">{t("plex.filter.collectionEmpty")}</p>
) : (
<div className={GRID}>
{collections.map((c) => (
<PosterTile
key={c.id}
title={c.title}
art={c.art}
subtitle={collectionMeta(c, t)}
topRight={
isIntrinsicReadOnly(c) ? (
<Lock
className="h-3.5 w-3.5 text-white/80 drop-shadow"
aria-label={t("plex.collection.readOnly")}
/>
) : undefined
}
onClick={() => onPick(c.id, c.title)}
className="h-28"
/>
))}
</div>
)}
</div>
);
}
+8 -1
View File
@@ -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=<kind>:<args>", () => {
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", () => {
+16 -1
View File
@@ -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=<kind>:<args> 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<Sub> = {
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<Sub> = {
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<Sub> = {
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<Sub> = {
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;
@@ -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();
});
});
@@ -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, unknown>) => string;
export function usePlexActiveFilters(): ActiveFilter[] {
const { show, filters } = usePlex();
const { setShow, setFilters } = usePlexActions();
const { t } = useTranslation();
const patch = (p: Partial<PlexFilters>) => 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<PlexFilters>) => 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") {
+36 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+36 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+7 -1
View File
@@ -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";
+84
View File
@@ -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<string, unknown>).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));
});
});
+73 -9
View File
@@ -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<PlexCollection, "smart" | "source">): 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<string, string>; // 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<string, unknown>): PlexFilters {
const {
sortDir: _drop,
collection,
collectionTitle,
...rest
} = raw as Record<string, unknown> & {
collection?: string | null;
collectionTitle?: string | null;
};
const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...(rest as Partial<PlexFilters>) };
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<PlexCollectionDetail> =>
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<PlexDiscover> => {
// 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<PlexDiscover> => {
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<PlexShowDetail> =>
+21 -9
View File
@@ -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<string, unknown>;
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<SavedView[]> => req("/api/saved-views"),
createView: (name: string, filters: FeedFilters): Promise<SavedView> =>
req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }),
savedViews: (source: ViewSource = "feed"): Promise<SavedView[]> =>
req(`/api/saved-views?source=${source}`),
createView: (
name: string,
filters: Record<string, unknown>,
source: ViewSource = "feed",
): Promise<SavedView> =>
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<string, unknown>; is_default: boolean }>,
): Promise<SavedView> =>
req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteView: (id: number): Promise<{ deleted: number }> =>
+66
View File
@@ -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);
});
});
+60 -1
View File
@@ -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 <main> 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<T>(
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(),
};
+13
View File
@@ -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%))`;
}
+20
View File
@@ -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"]);
});
});
+7 -3
View File
@@ -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.
+19
View File
@@ -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",
+20 -15
View File
@@ -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 =
<ConfirmProvider>
<NavigationProvider>
<FeedFiltersProvider>
<SavedViewsProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</SavedViewsProvider>
<PlaylistsProvider>
<PlexProvider>
<PlexViewBridge>
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
source (feed ⟷ plex) by the active page and read the right live filters. */}
<SavedViewsProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</SavedViewsProvider>
</PlexViewBridge>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</ConfirmProvider>