diff --git a/VERSION b/VERSION index d9bd9500..f91d144a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.70.0 \ No newline at end of file +0.71.0 \ No newline at end of file diff --git a/backend/alembic/versions/0071_plex_tmdb_fields.py b/backend/alembic/versions/0071_plex_tmdb_fields.py new file mode 100644 index 00000000..5342f108 --- /dev/null +++ b/backend/alembic/versions/0071_plex_tmdb_fields.py @@ -0,0 +1,52 @@ +"""plex TMDB enrichment columns (E5 S2) + +Additive TMDB-sourced fields on plex_items + plex_shows — keywords, full crew, budget/revenue, TMDb +community score, production companies/countries, recommendations, and a backdrop-path pool — plus a +`tmdb_id` cache and a `tmdb_enriched_at` watermark. Filled by the new TMDB enrichment job (matched by +the stored canonical `guid`), SERVED FROM THE ROW (never a live TMDB call). Distinct from the existing +Plex-sourced `imdb_rating`/`rating`, which are left untouched. Plus `plex_people.tmdb_person_id` (the S2 +foundation for S6's person bios). ADD-only, all nullable → no table rewrite, no ACCESS EXCLUSIVE lock. +No watermark reset needed: `tmdb_enriched_at` starts NULL, so every eligible row is picked up once. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0071_plex_tmdb_fields" +down_revision: Union[str, None] = "0070_fold_maintenance_batch" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLES = ("plex_items", "plex_shows") + +# (name, type) — same set on both movie/leaf rows and show rows. budget/revenue stay NULL for shows. +_COLS: tuple[tuple[str, sa.types.TypeEngine], ...] = ( + ("tmdb_id", sa.Integer()), + ("tmdb_enriched_at", sa.DateTime(timezone=True)), + ("tmdb_rating", sa.Float()), + ("tmdb_vote_count", sa.Integer()), + ("budget", sa.BigInteger()), + ("revenue", sa.BigInteger()), + ("keywords", postgresql.JSONB()), + ("crew", postgresql.JSONB()), + ("studios", postgresql.JSONB()), + ("production_countries", postgresql.JSONB()), + ("recommendations", postgresql.JSONB()), + ("backdrops", postgresql.JSONB()), +) + + +def upgrade() -> None: + for t in _TABLES: + for name, type_ in _COLS: + op.add_column(t, sa.Column(name, type_, nullable=True)) + op.add_column("plex_people", sa.Column("tmdb_person_id", sa.Integer(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("plex_people", "tmdb_person_id") + for t in _TABLES: + for name, _ in reversed(_COLS): + op.drop_column(t, name) diff --git a/backend/alembic/versions/0072_plex_person_bio.py b/backend/alembic/versions/0072_plex_person_bio.py new file mode 100644 index 00000000..5ef253fd --- /dev/null +++ b/backend/alembic/versions/0072_plex_person_bio.py @@ -0,0 +1,40 @@ +"""plex person TMDB bio columns (E5 S6) + +Additive TMDB-sourced person fields on plex_people — biography, birth/death dates, place of birth, +known-for department, also-known-as, and a `known_for` credit pool ([{tmdb_id, media_type, title}] +resolved to library rows by guid at serve time) — plus a `tmdb_enriched_at` watermark. Filled the +first time a person page is opened (from /person/{tmdb_person_id}), then SERVED FROM THE ROW. The +`tmdb_person_id` link itself already exists (0071). ADD-only, all nullable → no table rewrite, no +ACCESS EXCLUSIVE lock. `tmdb_enriched_at` starts NULL, so every person is enriched once on first view. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0072_plex_person_bio" +down_revision: Union[str, None] = "0071_plex_tmdb_fields" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_COLS: tuple[tuple[str, sa.types.TypeEngine], ...] = ( + ("biography", sa.Text()), + ("birthday", sa.String(length=10)), + ("deathday", sa.String(length=10)), + ("place_of_birth", sa.Text()), + ("known_for_department", sa.String(length=64)), + ("also_known_as", postgresql.JSONB()), + ("known_for", postgresql.JSONB()), + ("tmdb_enriched_at", sa.DateTime(timezone=True)), +) + + +def upgrade() -> None: + for name, type_ in _COLS: + op.add_column("plex_people", sa.Column(name, type_, nullable=True)) + + +def downgrade() -> None: + for name, _ in reversed(_COLS): + op.drop_column("plex_people", name) diff --git a/backend/app/config.py b/backend/app/config.py index ac399007..ec5d571e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -245,6 +245,20 @@ class Settings(BaseSettings): # local disk, so the default is fine there. plex_hls_dir: str = "" + # --- TMDB enrichment (E5 S2; optional — enriches the Plex catalog from TMDB) --- + # TMDB API key (v3 api_key, 32 hex chars). Server-side only; stored encrypted when set via the + # admin Config page. Empty → the TMDB enrichment job no-ops. Feeds keywords / crew / budget / + # revenue / TMDb score / recommendations / backdrops onto the mirrored Plex rows, matched by the + # stored canonical guid, and SERVED FROM THE DB — never a live TMDB call at serve time. + tmdb_api_key: str = "" + tmdb_api_base: str = "https://api.themoviedb.org/3" + # Metadata language for TMDB fetches (keyword/overview locale). Stored EN by default. + tmdb_language: str = "en-US" + # How often the TMDB enrichment job fetches a batch of not-yet-enriched Plex titles, in minutes. + tmdb_enrich_interval_min: int = 120 + # Titles fetched per TMDB enrichment run (TMDB is fast + generously rate-limited). + tmdb_enrich_batch: int = 100 + @property def allowed_email_set(self) -> set[str]: return {e.strip().lower() for e in self.allowed_emails.split(",") if e.strip()} diff --git a/backend/app/models.py b/backend/app/models.py index c1b44254..e449b185 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1067,6 +1067,20 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link tagline: Mapped[str | None] = mapped_column(Text) full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}] + # --- TMDB enrichment (E5 S2): see the identical block on plex_items for the full rationale. Additive; + # served from the row; NULL until enriched. (`budget`/`revenue` stay NULL for shows — TV has none.) --- + tmdb_id: Mapped[int | None] = mapped_column(Integer) + tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10) + tmdb_vote_count: Mapped[int | None] = mapped_column(Integer) + budget: Mapped[int | None] = mapped_column(BigInteger) + revenue: Mapped[int | None] = mapped_column(BigInteger) + keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] + crew: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{name, job, tmdb_id, thumb}] + studios: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB production companies [str] + production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] + recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) + backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched, # so serving falls back to the EN columns. `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) @@ -1184,6 +1198,24 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): imdb_id: Mapped[str | None] = mapped_column(String(16)) # tt… → the clickable IMDb link tagline: Mapped[str | None] = mapped_column(Text) full_cast: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # ordered [{name, role, thumb}] + # --- TMDB enrichment (E5 S2): matched by the canonical `guid` ("tmdb:12345"), filled by the TMDB + # backfill job, SERVED FROM THIS ROW (never a live TMDB call). Additive — imdb_rating/rating + # above stay Plex-sourced; `tmdb_rating` is the distinct TMDb community score. NULL until enriched; + # `tmdb_enriched_at` is the "tried" watermark (stamped on a definitive no-match too, so an + # unresolvable title isn't re-fetched every run; left NULL only on a transient fetch error). --- + tmdb_id: Mapped[int | None] = mapped_column(Integer) + tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + tmdb_rating: Mapped[float | None] = mapped_column(Float) # TMDb vote_average (0–10) + tmdb_vote_count: Mapped[int | None] = mapped_column(Integer) + budget: Mapped[int | None] = mapped_column(BigInteger) # USD (movies; TV has none) + revenue: Mapped[int | None] = mapped_column(BigInteger) + keywords: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] + crew: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{name, job, tmdb_id, thumb}] + studios: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB production companies [str] + production_countries: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] + # [{tmdb_id, title, poster, media_type}] — resolved to library rows (by guid) at serve time. + recommendations: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) + backdrops: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # TMDB backdrop file_paths [str] (crossfade pool, S8) # Hungarian-metadata twin fields, written by the overlay sync (E5 S5). NULL = no HU twin matched # (serving falls back to the EN columns). `people_text` is language-neutral, reused for both vectors. title_hu: Mapped[str | None] = mapped_column(String(512)) @@ -1224,6 +1256,21 @@ class PlexPerson(Base, TimestampMixin, UpdatedAtMixin): id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] = mapped_column(String(255), unique=True, index=True) photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None + # TMDB person id, linked by name from a title's TMDB credits (E5 S2 lays the foundation; the /person + # biography fetch + person-page render land in S6). NULL until a TMDB-enriched title names this person. + tmdb_person_id: Mapped[int | None] = mapped_column(Integer) + # --- TMDB person enrichment (E5 S6): fetched from /person/{tmdb_person_id} the first time the person + # page is opened, then SERVED FROM THE ROW. `tmdb_enriched_at` is the watermark (set even on a + # no-op fetch so it isn't retried every open). `known_for` = top combined-credits [{tmdb_id, + # media_type, title}], resolved to library rows by guid at serve time (like recommendations). --- + biography: Mapped[str | None] = mapped_column(Text) + birthday: Mapped[str | None] = mapped_column(String(10)) # "YYYY-MM-DD", served as-is + deathday: Mapped[str | None] = mapped_column(String(10)) + place_of_birth: Mapped[str | None] = mapped_column(Text) + known_for_department: Mapped[str | None] = mapped_column(String(64)) + also_known_as: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [str] + known_for: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) # [{tmdb_id, media_type, title}] + tmdb_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class PlexCollection(Base, TimestampMixin, UpdatedAtMixin): diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py index e8abf9a4..da24097e 100644 --- a/backend/app/plex/cards.py +++ b/backend/app/plex/cards.py @@ -6,14 +6,32 @@ router. Pure functions — no DB, no request state.""" from app.models import PlexItem, PlexShow, PlexState +# A card's wide backdrop, for a rail's landscape tile or a crossfading hero — but ONLY when the item +# actually has art. `thumb` is always emitted (its endpoint placeholders a missing poster), but an +# `art` URL for an item with no `art_key` would resolve to a broken/blank image on every card that +# tried to use it, so gate it on the stored key and let the client fall back to the poster. +def _art_url(rating_key: str, art_key: str | None) -> str | None: + return f"/api/plex/image/{rating_key}?variant=art" if art_key else None + + +# Card hover-overview: a short synopsis for the reveal panel, trimmed so a strip/related row of cards +# stays a few hundred bytes each rather than carrying full synopses. (These non-grid card paths serve +# the EN summary/rating; the main grid coalesces the HU twin in browse.unified_library.) +def _card_summary(summary: str | None) -> str | None: + return (summary or "")[:300] or None + + def movie_card(it: PlexItem, st: PlexState | None) -> dict: return { "id": it.rating_key, "type": "movie", "title": it.title, "year": it.year, + "rating": it.rating, + "summary": _card_summary(it.summary), "duration_seconds": it.duration_s, "thumb": f"/api/plex/image/{it.rating_key}", + "art": _art_url(it.rating_key, it.art_key), "playable": it.playable, "status": st.status if st else "new", "position_seconds": st.position_seconds if st else 0, @@ -26,7 +44,10 @@ def show_card(sh: PlexShow, status: str = "new") -> dict: "type": "show", "title": sh.title, "year": sh.year, + "rating": sh.rating, + "summary": _card_summary(sh.summary), "thumb": f"/api/plex/image/{sh.rating_key}", + "art": _art_url(sh.rating_key, sh.art_key), "season_count": sh.child_count, # Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge. "status": status, diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py index ca3b31ce..71734322 100644 --- a/backend/app/plex/query.py +++ b/backend/app/plex/query.py @@ -29,6 +29,11 @@ def title_col(model, lang: str): return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title +def summary_col(model, lang: str): + """DISPLAY summary, HU-coalesced (EN fallback) — the card hover-overview + info page read this.""" + return func.coalesce(model.summary_hu, model.summary) if lang == "hu" else model.summary + + def genres_col(model, lang: str): # type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce. if lang == "hu": @@ -135,6 +140,8 @@ def meta_filter_conds(model, p: dict, lang: str = "en") -> list: stds = split_csv(p.get("studios")) if stds: # OR 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"]])) return conds @@ -166,6 +173,7 @@ class LibraryFilters: "any" # how multiple actors combine: any (OR, default) | all (AND) ) studios: str | None = None + keywords: str | None = None # TMDB keyword chips (E5 S5), CSV; language-neutral collection: str | None = None def as_dict(self) -> dict: @@ -183,6 +191,7 @@ class LibraryFilters: "actors": self.actors, "actor_mode": self.actor_mode, "studios": self.studios, + "keywords": self.keywords, "collection": self.collection, } diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py index fa762610..11e173fd 100644 --- a/backend/app/plex/rollup.py +++ b/backend/app/plex/rollup.py @@ -42,27 +42,32 @@ def rollup(cards: list[dict]) -> dict: } -def agg_subquery(db: Session, user_id: int): +def agg_subquery(db: Session, user_id: int, include_resumed: bool = False): """Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show - grid by aggregate watch-state and (b) compute each card's badge.""" + grid by aggregate watch-state, (b) compute each card's badge, and (c) drive the Discover + continue-watching rail. `include_resumed` adds `resumed_at` = max(progress_updated_at) across the + show's episodes, so the rail can order shows by how recently they were last progressed.""" ps = aliased(PlexState) - return ( - select( - PlexItem.show_id.label("show_id"), - func.count(PlexItem.id).label("total"), - func.count(case((ps.status == "watched", 1))).label("watched"), - func.count( - case( - ( - and_( - ps.position_seconds > 0, - or_(ps.status.is_(None), ps.status != "watched"), - ), - 1, - ) + cols = [ + PlexItem.show_id.label("show_id"), + func.count(PlexItem.id).label("total"), + func.count(case((ps.status == "watched", 1))).label("watched"), + func.count( + case( + ( + and_( + ps.position_seconds > 0, + or_(ps.status.is_(None), ps.status != "watched"), + ), + 1, ) - ).label("inprog"), - ) + ) + ).label("inprog"), + ] + if include_resumed: + cols.append(func.max(ps.progress_updated_at).label("resumed_at")) + return ( + select(*cols) .outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id)) .where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None)) .group_by(PlexItem.show_id) @@ -76,3 +81,21 @@ def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]: agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids)) ).all() return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows} + + +def item_state_map( + db: Session, user_id: int, item_ids: list[int] +) -> dict[int, PlexState]: + """A user's PlexState rows for a set of leaf items (movies or episodes), keyed by item_id — the + leaf analogue of `state_map` (which rolls a show's episodes up to a status string). One batched + fetch, so the caller serializes cards without a per-card query. Empty in → empty dict (no `IN ()`).""" + if not item_ids: + return {} + return { + s.item_id: s + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id.in_(item_ids) + ) + ).scalars() + } diff --git a/backend/app/plex/tmdb.py b/backend/app/plex/tmdb.py new file mode 100644 index 00000000..abf30f6d --- /dev/null +++ b/backend/app/plex/tmdb.py @@ -0,0 +1,492 @@ +"""TMDB enrichment for the mirrored Plex catalog (E5 S2). + +A thin synchronous TMDB v3 client plus the parse/persist of the enrichment fields onto plex_items / +plex_shows, matched by the stored canonical `guid` ("tmdb:12345", or imdb/tvdb resolved via /find). +Server-side ONLY (the api_key from sysconfig). Everything fetched is PERSISTED and served from the DB +row — there is never a live TMDB call at serve time (the user's explicit rule). Additive: the existing +Plex-sourced `imdb_rating` / `rating` are left untouched; `tmdb_rating` is the distinct TMDb score. + +One-parser rule (mirrors app/plex/detail.py): `parse_tmdb` is the pure parser, `apply_tmdb_detail` writes +the row. The enrich batch + scheduler entry live here too so the whole TMDB integration is one module. +""" +import logging +import time +from datetime import datetime, timezone + +import httpx +from sqlalchemy import func, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.orm import Session + +from app import sysconfig +from app.config import settings +from app.models import PlexItem, PlexPerson, PlexShow + +log = logging.getLogger("siftlode.tmdb") + +# Politeness throttle: TMDB tolerates ~50 req/s; ~33/s leaves comfortable headroom for a shared host. +_MIN_INTERVAL = 0.03 + +# The crew jobs worth surfacing on an info page, in DISPLAY-priority order (the stored crew is sorted by +# this — TMDB's own crew order is arbitrary and buries the lead roles). MOVIE and TV differ on purpose: +# on a TV show `Director`/`Editor`/`Director of Photography` are PER-EPISODE credits (dozens of names +# that would flood the list and bury the showrunner), so TV keys on `Creator` (from the payload's +# top-level `created_by`, not the crew list) + the show-level producers/writers instead. +_MOVIE_CREW_JOBS = ( + "Director", + "Writer", + "Screenplay", + "Story", + "Producer", + "Executive Producer", + "Editor", + "Director of Photography", + "Original Music Composer", +) +_TV_CREW_JOBS = ("Creator", "Executive Producer", "Writer", "Producer", "Original Music Composer") +_MOVIE_PRIORITY = {job: i for i, job in enumerate(_MOVIE_CREW_JOBS)} +_TV_PRIORITY = {job: i for i, job in enumerate(_TV_CREW_JOBS)} + +# append_to_response bundles the sub-resources into ONE request per title (keywords + credits + +# recommendations + images + external ids). TV uses aggregate_credits (per-person aggregated jobs). +_MOVIE_APPEND = "keywords,credits,recommendations,images,external_ids" +_TV_APPEND = "keywords,aggregate_credits,recommendations,images,external_ids" + + +class TmdbError(Exception): + """Any transient failure talking to TMDB (network, non-404 HTTP, bad payload). The caller leaves + the row's `tmdb_enriched_at` NULL so the next run retries.""" + + +class TmdbNotConfigured(TmdbError): + """No TMDB API key is set — the module can't do anything.""" + + +class TmdbClient: + """Bound to the configured api_key + language. Use as a context manager so the httpx client closes.""" + + def __init__(self, db: Session, timeout: float = 30.0): + self.key = sysconfig.get_str(db, "tmdb_api_key") + if not self.key: + raise TmdbNotConfigured("TMDB API key is not configured") + self.base = settings.tmdb_api_base.rstrip("/") + self.language = sysconfig.get_str(db, "tmdb_language") or settings.tmdb_language + # `timeout` is caller-tunable so the request-path lazy person enrich can cap how long it may + # block a worker thread (the batch job keeps the generous default). + self._http = httpx.Client(timeout=timeout) + self._last = 0.0 + + def __enter__(self) -> "TmdbClient": + return self + + def __exit__(self, *exc) -> None: + self._http.close() + + def close(self) -> None: + self._http.close() + + def _throttle(self) -> None: + wait = self._last + _MIN_INTERVAL - time.monotonic() + if wait > 0: + time.sleep(wait) + self._last = time.monotonic() + + def _get(self, path: str, params: dict | None = None) -> dict | None: + """GET a TMDB path. Returns the JSON dict, None on a 404 (a definitive "no such id"), and raises + TmdbError on any transient failure (network / other HTTP status / non-JSON).""" + self._throttle() + p = {"api_key": self.key, "language": self.language, **(params or {})} + try: + r = self._http.get(f"{self.base}{path}", params=p) + except httpx.HTTPError as e: + # NEVER put str(e)/the URL in the message: the request URL carries `?api_key=` in + # its query string, and this message is logged (enrich_batch). Report the type + path only. + raise TmdbError(f"TMDB request error on {path}: {type(e).__name__}") from None + if r.status_code == 404: + return None + try: + r.raise_for_status() + except httpx.HTTPStatusError as e: + # Same reason: the HTTPStatusError string embeds the full URL incl. the api_key. Sever the + # cause (`from None`) so no traceback can resurface it, and log only status + path. + raise TmdbError(f"TMDB {path} returned {e.response.status_code}") from None + try: + return r.json() + except ValueError as e: + raise TmdbError("TMDB returned a non-JSON response") from e + + def fetch(self, tmdb_id: int, media_type: str) -> dict | None: + """Full enrichment payload for a movie or show (one request via append_to_response).""" + if media_type == "tv": + return self._get(f"/tv/{tmdb_id}", {"append_to_response": _TV_APPEND}) + return self._get(f"/movie/{tmdb_id}", {"append_to_response": _MOVIE_APPEND}) + + def find(self, external_id: str, source: str) -> dict | None: + """Resolve an external id (imdb_id / tvdb_id) to TMDB items via /find.""" + return self._get(f"/find/{external_id}", {"external_source": source}) + + def fetch_person(self, person_id: int) -> dict | None: + """One person's bio + combined credits (E5 S6), in a single append_to_response request.""" + return self._get(f"/person/{person_id}", {"append_to_response": "combined_credits"}) + + +def guid_to_tmdb_id(guid: str | None, media_type: str, tmdb: TmdbClient) -> int | None: + """Map a stored canonical guid to a TMDB id. `tmdb:` is direct; `imdb:`/`tvdb:` go through /find + (picking the movie_results or tv_results list per `media_type`). Returns None for an unmatchable + guid — the caller then stamps the watermark so it isn't retried every run.""" + if not guid or ":" not in guid: + return None + provider, _, ext = guid.partition(":") + if provider == "tmdb": + try: + return int(ext) + except ValueError: + return None + source = {"imdb": "imdb_id", "tvdb": "tvdb_id"}.get(provider) + if not source: + return None + res = tmdb.find(ext, source) + if not res: + return None + items = res.get("tv_results" if media_type == "tv" else "movie_results") or [] + return items[0].get("id") if items else None + + +def _dedup_crew(crew: list[dict]) -> list[dict]: + seen: set[tuple] = set() + out: list[dict] = [] + for c in crew: + key = (c["name"], c["job"]) + if key in seen: + continue + seen.add(key) + out.append(c) + return out + + +def parse_tmdb(payload: dict, media_type: str) -> dict: + """Pure parser: pull the enrichment fields out of a TMDB movie/tv payload (append_to_response form). + Best-effort — any missing sub-resource is just omitted. Returns the row fields plus a `people` list + ([{name, tmdb_id}] over cast+crew) for the plex_people id-linking. TMDB image fields are stored as + bare paths ("/abc.jpg"); the client builds the image.tmdb.org URL.""" + is_tv = media_type == "tv" + votes = payload.get("vote_count") or 0 + + kw_container = payload.get("keywords") or {} + kw_list = kw_container.get("results") if is_tv else kw_container.get("keywords") + keywords = [k.get("name") for k in (kw_list or []) if k.get("name")][:20] + + priority = _TV_PRIORITY if is_tv else _MOVIE_PRIORITY + credits = payload.get("aggregate_credits" if is_tv else "credits") or {} + crew: list[dict] = [] + # A TV show's creators live at the top level (created_by), not in the crew list — surface them. + if is_tv: + for c in payload.get("created_by") or []: + if c.get("name"): + crew.append( + {"name": c["name"], "job": "Creator", "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")} + ) + for c in credits.get("crew") or []: + # Movie crew entries carry `job`; TV aggregate_credits carry a `jobs` array per person. + job = (c.get("jobs") or [{}])[0].get("job") if is_tv else c.get("job") + if job in priority and c.get("name"): + crew.append( + {"name": c["name"], "job": job, "tmdb_id": c.get("id"), "profile_path": c.get("profile_path")} + ) + # Dedup by (name, job) — a person can hold two distinct key roles (Coppola: Director + Writer) — then + # order by display priority so the lead roles come first, and cap. + crew = _dedup_crew(crew) + crew.sort(key=lambda c: priority.get(c["job"], 99)) + crew = crew[:12] + + # name -> tmdb person id, for the plex_people foundation (S6 fetches bios off these ids). + people: list[dict] = [] + for c in (credits.get("cast") or [])[:30]: + if c.get("name") and c.get("id"): + people.append({"name": c["name"], "tmdb_id": c["id"]}) + for c in crew: + if c.get("tmdb_id"): + people.append({"name": c["name"], "tmdb_id": c["tmdb_id"]}) + + recs: list[dict] = [] + for r in ((payload.get("recommendations") or {}).get("results") or [])[:20]: + recs.append( + { + "tmdb_id": r.get("id"), + "title": r.get("title") or r.get("name"), + "poster_path": r.get("poster_path"), + "media_type": r.get("media_type") or media_type, + } + ) + + backdrops = [ + b.get("file_path") + for b in ((payload.get("images") or {}).get("backdrops") or []) + if b.get("file_path") + ][:10] + + return { + "tmdb_id": payload.get("id"), + # vote_average is meaningless with no votes (TMDB returns 0.0) → treat as unknown. + "tmdb_rating": payload.get("vote_average") if votes else None, + "tmdb_vote_count": votes or None, + # 0 is TMDB's "unknown" for money fields; TV has neither. + "budget": (payload.get("budget") or None) if not is_tv else None, + "revenue": (payload.get("revenue") or None) if not is_tv else None, + "keywords": keywords or None, + "crew": crew or None, + "studios": [c.get("name") for c in (payload.get("production_companies") or []) if c.get("name")][:8] + or None, + "production_countries": [ + c.get("name") for c in (payload.get("production_countries") or []) if c.get("name") + ] + or None, + "recommendations": recs or None, + "backdrops": backdrops or None, + "people": people, + } + + +def apply_tmdb_detail(row, parsed: dict) -> None: + """Persist the parsed TMDB fields onto a movie/show row. Does NOT touch full_cast / imdb_rating / + rating (Plex-owned). The caller stamps `tmdb_enriched_at` and links people separately.""" + row.tmdb_id = parsed["tmdb_id"] + row.tmdb_rating = parsed["tmdb_rating"] + row.tmdb_vote_count = parsed["tmdb_vote_count"] + row.budget = parsed["budget"] + row.revenue = parsed["revenue"] + row.keywords = parsed["keywords"] + row.crew = parsed["crew"] + row.studios = parsed["studios"] + row.production_countries = parsed["production_countries"] + row.recommendations = parsed["recommendations"] + row.backdrops = parsed["backdrops"] + + +def _link_people(db: Session, links: dict[str, int]) -> None: + """Upsert plex_people by name, stamping `tmdb_person_id` — keeping any id already set (first wins), + filling it where NULL, inserting a bare row where the person isn't cached yet. Deduped by name so a + single statement can't hit the conflict target twice (the same rule as people.enrich_row_people).""" + values = [{"name": nm, "tmdb_person_id": pid} for nm, pid in links.items() if nm and pid] + if not values: + return + stmt = pg_insert(PlexPerson).values(values) + db.execute( + stmt.on_conflict_do_update( + index_elements=[PlexPerson.name], + set_={"tmdb_person_id": func.coalesce(PlexPerson.tmdb_person_id, stmt.excluded.tmdb_person_id)}, + ) + ) + + +def enrich_batch(db: Session, tmdb: TmdbClient, limit: int) -> int: + """Enrich up to `limit` not-yet-TMDB-enriched titles (movies first, shows fill the remainder — + mirrors _enrich_people_batch). A transient TmdbError leaves the watermark NULL for retry; a + definitive no-match (unresolvable guid / TMDB 404) still stamps the watermark so it isn't re-fetched + every run. One commit at the end. Returns the number processed (matched OR definitively unmatched).""" + movies = ( + db.execute( + select(PlexItem) + .where( + PlexItem.kind == "movie", + PlexItem.guid.is_not(None), + PlexItem.tmdb_enriched_at.is_(None), + ) + .order_by(PlexItem.id) + .limit(limit) + ) + .scalars() + .all() + ) + shows: list[PlexShow] = [] + if len(movies) < limit: + shows = ( + db.execute( + select(PlexShow) + .where(PlexShow.guid.is_not(None), PlexShow.tmdb_enriched_at.is_(None)) + .order_by(PlexShow.id) + .limit(limit - len(movies)) + ) + .scalars() + .all() + ) + + processed = 0 + links: dict[str, int] = {} + for row, media_type in [(m, "movie") for m in movies] + [(s, "tv") for s in shows]: + try: + tmdb_id = guid_to_tmdb_id(row.guid, media_type, tmdb) + payload = tmdb.fetch(tmdb_id, media_type) if tmdb_id else None + except TmdbError as e: + log.warning("tmdb enrich %s failed (will retry): %s", row.rating_key, e) + continue # transient — leave tmdb_enriched_at NULL for the next run + if payload: + parsed = parse_tmdb(payload, media_type) + apply_tmdb_detail(row, parsed) + for p in parsed["people"]: + links.setdefault(p["name"], p["tmdb_id"]) + # Stamp even on a definitive no-match so an unresolvable title isn't re-fetched every run. + row.tmdb_enriched_at = datetime.now(timezone.utc) + processed += 1 + + if links: + _link_people(db, links) + db.commit() + return processed + + +def run_enrich(db: Session) -> dict: + """Scheduler entry point. No-op (never an error) when the TMDB key isn't configured.""" + if not sysconfig.get_str(db, "tmdb_api_key"): + return {"enriched": 0, "skipped": "tmdb_api_key not set"} + with TmdbClient(db) as tmdb: + n = enrich_batch(db, tmdb, settings.tmdb_enrich_batch) + return {"enriched": n} + + +def resolve_library_guids( + db: Session, + entries: list[dict] | None, + *, + movie_lib_ids: list[int] | None = None, + show_lib_ids: list[int] | None = None, +) -> tuple[dict[str, PlexItem], dict[str, PlexShow]]: + """Match a list of TMDB credits/recs (each `{tmdb_id, media_type, …}`) to our library rows by the + canonical guid `tmdb:`, returning `(movies_by_guid, shows_by_guid)` of the ORM rows. + + ⭐ TMDB movie ids and tv ids are INDEPENDENT sequences (id 603 can be BOTH a movie and a show), so + each entry matches ONLY its own media_type's table — a movie credit against a library movie, a tv + credit against a library show; a blind numeric match would resolve to an unrelated same-id title. + Pass `movie_lib_ids`/`show_lib_ids` to scope to the enabled libraries (the person page); leave them + None to match across all libraries (the detail-page recs). An explicit empty list means "that side + has no enabled library" → its bucket is skipped entirely.""" + if not entries: + return {}, {} + movie_guids = [ + f"tmdb:{e['tmdb_id']}" for e in entries if e.get("tmdb_id") and e.get("media_type") != "tv" + ] + tv_guids = [ + f"tmdb:{e['tmdb_id']}" for e in entries if e.get("tmdb_id") and e.get("media_type") == "tv" + ] + movies: dict[str, PlexItem] = {} + if movie_guids and movie_lib_ids != []: + conds = [PlexItem.kind == "movie", PlexItem.guid.in_(movie_guids)] + if movie_lib_ids is not None: + conds.append(PlexItem.library_id.in_(movie_lib_ids)) + for m in db.execute(select(PlexItem).where(*conds)).scalars(): + movies.setdefault(m.guid, m) + shows: dict[str, PlexShow] = {} + if tv_guids and show_lib_ids != []: + conds = [PlexShow.guid.in_(tv_guids)] + if show_lib_ids is not None: + conds.append(PlexShow.library_id.in_(show_lib_ids)) + for s in db.execute(select(PlexShow).where(*conds)).scalars(): + shows.setdefault(s.guid, s) + return movies, shows + + +def resolve_recs(db: Session, recs: list[dict] | None) -> list[dict]: + """Tag each recommendation with whether it's in OUR library (matched by guid `tmdb:`, bucketed + by media_type — see resolve_library_guids) and, if so, its rating_key — so the client can deep-link + the ones we have and merely display the rest.""" + if not recs: + return [] + movies, shows = resolve_library_guids(db, recs) + out: list[dict] = [] + for r in recs: + g = f"tmdb:{r.get('tmdb_id')}" + row = shows.get(g) if r.get("media_type") == "tv" else movies.get(g) + out.append( + {**r, "in_library": row is not None, "rating_key": row.rating_key if row else None} + ) + return out + + +def serve_block(db: Session, row) -> dict | None: + """The TMDB extras for a movie/show detail response, served from the row (no live TMDB call). + None until the row has been TMDB-enriched (the client then shows nothing extra).""" + if row.tmdb_enriched_at is None: + return None + return { + "tmdb_id": row.tmdb_id, + "rating": row.tmdb_rating, + "vote_count": row.tmdb_vote_count, + "budget": row.budget, + "revenue": row.revenue, + "keywords": row.keywords or [], + "crew": row.crew or [], + "studios": row.studios or [], + "production_countries": row.production_countries or [], + "recommendations": resolve_recs(db, row.recommendations), + "backdrops": row.backdrops or [], + } + + +# --- Person enrichment (E5 S6) ------------------------------------------------------------------- +# A person's bio + birth facts + a "Known for" credit pool, fetched once (the first person-page open) +# and served from the plex_people row. `known_for` stores the top combined-credits ids; the person +# endpoint resolves them to library rows by guid at serve time (like recommendations). + +_KNOWN_FOR_MAX = 15 + + +def parse_person(payload: dict) -> dict: + """TMDB /person/{id} (+combined_credits) → the persisted bio fields. `known_for` = the person's + most-popular movie/tv credits (deduped by id), top `_KNOWN_FOR_MAX`, resolved to library later.""" + credits = (payload.get("combined_credits") or {}).get("cast") or [] + # Dedup on (id, media_type), not the bare id: TMDB movie ids and tv ids are independent sequences + # (id 603 can be both a movie and a show — see resolve_library_guids), so a same-id movie+show pair + # is two distinct credits and both must survive. + seen: set[tuple[int, str]] = set() + known: list[dict] = [] + for c in sorted(credits, key=lambda c: c.get("popularity") or 0, reverse=True): + tid, mt = c.get("id"), c.get("media_type") + if not tid or mt not in ("movie", "tv") or (tid, mt) in seen: + continue + seen.add((tid, mt)) + known.append({"tmdb_id": tid, "media_type": mt, "title": c.get("title") or c.get("name")}) + if len(known) >= _KNOWN_FOR_MAX: + break + return { + "biography": (payload.get("biography") or "").strip() or None, + "birthday": payload.get("birthday") or None, + "deathday": payload.get("deathday") or None, + "place_of_birth": payload.get("place_of_birth") or None, + "known_for_department": payload.get("known_for_department") or None, + "also_known_as": payload.get("also_known_as") or None, + "known_for": known or None, + } + + +def apply_person(row: PlexPerson, parsed: dict) -> None: + row.biography = parsed["biography"] + row.birthday = parsed["birthday"] + row.deathday = parsed["deathday"] + row.place_of_birth = parsed["place_of_birth"] + row.known_for_department = parsed["known_for_department"] + row.also_known_as = parsed["also_known_as"] + row.known_for = parsed["known_for"] + row.tmdb_enriched_at = datetime.now(timezone.utc) + + +def enrich_person(db: Session, person: PlexPerson) -> bool: + """Lazily fetch + persist one person's TMDB bio (first person-page open), committing the write so it + survives regardless of the read path. No-op (False) without a key or a `tmdb_person_id`; a transient + error leaves `tmdb_enriched_at` NULL so the next open retries. A 404 (deleted person) stamps the + watermark with empty fields so we don't refetch it every open.""" + if not person.tmdb_person_id or not sysconfig.get_str(db, "tmdb_api_key"): + return False + try: + # Called from a background task (off the request path — see people._enrich_person_bg), so it no + # longer blocks a serve; still cap the outbound TMDB call with a short timeout so a hanging TMDB + # doesn't tie up a threadpool worker for the default 30s. + with TmdbClient(db, timeout=8.0) as tmdb: + payload = tmdb.fetch_person(person.tmdb_person_id) + except TmdbError: + return False + if payload is None: + person.tmdb_enriched_at = datetime.now(timezone.utc) + else: + apply_person(person, parse_person(payload)) + db.commit() + return True diff --git a/backend/app/routes/plex/__init__.py b/backend/app/routes/plex/__init__.py index 60fbfc88..ce713a10 100644 --- a/backend/app/routes/plex/__init__.py +++ b/backend/app/routes/plex/__init__.py @@ -15,6 +15,7 @@ from app.routes.plex import ( browse, collections, detail, + discover, people, playback, playlists, @@ -25,6 +26,7 @@ router.include_router(admin.router) router.include_router(browse.router) router.include_router(collections.router) router.include_router(detail.router) +router.include_router(discover.router) router.include_router(people.router) router.include_router(playback.router) router.include_router(playlists.router) diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index f333ffb9..291a7569 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -38,6 +38,7 @@ from app.plex.query import ( movie_status_case, show_status_case, split_csv, + summary_col, title_col, vector_col, vector_match, @@ -130,6 +131,8 @@ def unified_library( title_col(PlexItem, lang).label("title"), PlexItem.year.label("year"), PlexItem.rating.label("rating"), + # Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis. + func.left(summary_col(PlexItem, lang), 300).label("summary"), PlexItem.added_at.label("added_at"), PlexItem.originally_available_at.label("rel"), PlexItem.duration_s.label("duration_s"), @@ -170,6 +173,7 @@ def unified_library( title_col(PlexShow, lang).label("title"), PlexShow.year.label("year"), PlexShow.rating.label("rating"), + func.left(summary_col(PlexShow, lang), 300).label("summary"), PlexShow.added_at.label("added_at"), PlexShow.originally_available_at.label("rel"), cast(null(), Integer).label("duration_s"), @@ -227,6 +231,8 @@ def unified_library( "type": "movie", "title": r.title, "year": r.year, + "rating": r.rating, + "summary": r.summary, "duration_seconds": r.duration_s, "thumb": f"/api/plex/image/{r.rk}", "playable": r.playable, @@ -241,6 +247,8 @@ def unified_library( "type": "show", "title": r.title, "year": r.year, + "rating": r.rating, + "summary": r.summary, "thumb": f"/api/plex/image/{r.rk}", "season_count": r.season_count, "status": r.status, @@ -304,6 +312,7 @@ def facets( empty = { "genres": [], "content_ratings": [], + "studios": [], "year_min": None, "year_max": None, "rating_max": None, @@ -347,6 +356,7 @@ def facets( genre_counts: dict[str, int] = {} cr_counts: dict[str, int] = {} + studio_counts: dict[str, int] = {} years: list[int] = [] ratings: list[float] = [] durs: list[int] = [] @@ -398,6 +408,21 @@ def facets( ) for cr, c in db.execute(crq).all(): cr_counts[cr] = cr_counts.get(cr, 0) + c + # Studios — exclude its own (each offered studio is a valid OR-addition that only widens). + stq = ( + wsq( + select(model.studio, func.count()), + model, + ids, + {**p, "studios": None}, + kind_movie, + True, + ) + .where(model.studio.isnot(None)) + .group_by(model.studio) + ) + for s, c in db.execute(stq).all(): + studio_counts[s] = studio_counts.get(s, 0) + c # Year bounds — exclude the year filter. by = db.execute( wsq( @@ -452,6 +477,9 @@ def facets( # undo a zero-result selection. Count 0 is fine (genre chips don't display counts). for g in split_csv(f.genres): genre_counts.setdefault(g, 0) + # Same for the actively-selected studios, so a selected studio stays removable even at zero matches. + for s in split_csv(f.studios): + studio_counts.setdefault(s, 0) return { "genres": [ {"value": g, "count": c} @@ -461,6 +489,10 @@ def facets( {"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1]) ], + "studios": [ + {"value": s, "count": c} + for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], kv[0])) + ], "year_min": min(years) if years else None, "year_max": max(years) if years else None, "rating_max": max(ratings) if ratings else None, diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py index a65ce916..36e3c6a0 100644 --- a/backend/app/routes/plex/detail.py +++ b/backend/app/routes/plex/detail.py @@ -23,6 +23,7 @@ from app.models import ( from app.plex import detail as plex_detail from app.plex import people as plex_people from app.plex import rollup, subtitles +from app.plex import tmdb as plex_tmdb from app.plex.cards import episode_card, movie_card, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.lookups import by_rating_key @@ -172,6 +173,8 @@ def show_detail( "first": show_roll["first"], "episode_count": show_roll["episode_count"], "collection_keys": sh.collection_keys or [], + # TMDB enrichment (E5 S2), served from the row. + "tmdb": plex_tmdb.serve_block(db, sh), }, "seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons], "related": related, @@ -435,4 +438,6 @@ def item_detail( "prev_id": prev_id, "next_id": next_id, "collections": _collection_strips(db, it, user.id), + # TMDB enrichment (E5 S2), served from the row — movies only (episodes aren't enriched). + "tmdb": plex_tmdb.serve_block(db, it) if it.kind == "movie" else None, } diff --git a/backend/app/routes/plex/discover.py b/backend/app/routes/plex/discover.py new file mode 100644 index 00000000..65e2c695 --- /dev/null +++ b/backend/app/routes/plex/discover.py @@ -0,0 +1,296 @@ +"""Plex Discover: the rail-surface read-model (E5 S4). + +A consolidated payload for the dedicated Discover landing — a stack of horizontal rails: +Continue watching · Recently added · Browse-by-genre tiles · Because you watched X — plus a +separate, reshuffling Surprise-me sample. Serve-from-DB only: the recommendation rail reads the +recs the S2 TMDB engine already persisted (`PlexItem.recommendations`), never a live TMDB call, and +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. +""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import and_, func, or_, select +from sqlalchemy.orm import Session + +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.tmdb import resolve_recs +from app.routes.plex._shared import _content_libs, _norm_lang + +router = APIRouter(prefix="/api/plex", tags=["plex"]) + +RAIL_LIMIT = 20 +GENRE_TILES = 12 + + +def _scope_libs(db: Session, scope: str) -> tuple[list[int], list[int]]: + """The movie/show library ids visible for this scope (empty list = out of scope).""" + movie_ids, show_ids = _content_libs(db) + return ( + movie_ids if scope in ("movie", "both") else [], + show_ids if scope in ("show", "both") else [], + ) + + +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] + + +def _show_cards(db: Session, uid: int, shows: list[PlexShow]) -> list[dict]: + statuses = rollup.state_map(db, uid, [s.id for s in shows]) + return [show_card(s, statuses.get(s.id, "new")) for s in shows] + + +def _recently_added( + db: Session, uid: int, movie_ids: list[int], show_ids: list[int] +) -> 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)) + .order_by(PlexItem.added_at.desc().nullslast()) + .limit(RAIL_LIMIT) + ).scalars().all() + if movie_ids + else [] + ) + shows = ( + db.execute( + select(PlexShow) + .where(PlexShow.library_id.in_(show_ids)) + .order_by(PlexShow.added_at.desc().nullslast()) + .limit(RAIL_LIMIT) + ).scalars().all() + if show_ids + else [] + ) + # Merge the two type-homogeneous lists by added_at, then serialize each side in one batch so the + # per-card state lookups stay batched (no N+1). The `(is not None, value)` key sorts a missing + # timestamp last under reverse. + tagged = [(m.added_at, "movie", m) for m in movies] + [ + (s.added_at, "show", s) for s in shows + ] + tagged.sort(key=lambda t: (t[0] is not None, t[0]), reverse=True) + top = tagged[:RAIL_LIMIT] + # Key the serialized cards back by rating_key (globally unique in Plex — a movie and a show never + # share one), so the merge doesn't depend on object identity staying alive. + top_movies = [o for _, kind, o in top if kind == "movie"] + top_shows = [o for _, kind, o in top if kind == "show"] + mcards = {m.rating_key: c for m, c in zip(top_movies, _movie_cards(db, uid, top_movies))} + scards = {s.rating_key: c for s, c in zip(top_shows, _show_cards(db, uid, top_shows))} + return [ + mcards[o.rating_key] if kind == "movie" else scards[o.rating_key] + for _, kind, o in top + ] + + +def _continue_watching( + db: Session, uid: int, movie_ids: list[int], show_ids: list[int] +) -> 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( + 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.position_seconds > 0, + # A NULL status is still "started" (mirrors apply_movie_state_filter's in_progress) — + # `notin_` alone would drop it, since `NULL NOT IN (...)` is NULL, not TRUE. + or_( + PlexState.status.is_(None), + PlexState.status.notin_(["watched", "hidden"]), + ), + ) + .order_by(PlexState.progress_updated_at.desc().nullslast()) + .limit(RAIL_LIMIT) + ).all() + for it, st in mrows: + rows.append((st.progress_updated_at, movie_card(it, st))) + + if show_ids: + # Reuse the shared per-show episode rollup (total/watched/inprog) rather than re-inlining it, plus + # 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() + 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)) + .order_by(prog.c.resumed_at.desc().nullslast()) + .limit(RAIL_LIMIT) + ).all() + for sh, resumed_at in srows: + rows.append((resumed_at, show_card(sh, "in_progress"))) + + rows.sort(key=lambda r: (r[0] is not None, r[0]), reverse=True) + return [card for _, card in rows[:RAIL_LIMIT]] + + +def _genre_tiles( + db: Session, movie_ids: list[int], show_ids: list[int], 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.""" + 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" + ) + if model is PlexItem: + base = base.where(model.kind == "movie") + expanded = base.subquery() + for g, c in db.execute( + select(expanded.c.g, func.count()).group_by(expanded.c.g) + ).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] + + +def _because_you_watched( + db: Session, uid: int, movie_ids: list[int], show_ids: list[int] +) -> 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.""" + if not movie_ids: + return None + anchor = db.execute( + select(PlexItem) + .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", + PlexItem.recommendations.isnot(None), + ) + .order_by(PlexState.watched_at.desc().nullslast()) + .limit(1) + ).scalars().first() + if anchor is None: + return None + resolved = resolve_recs(db, anchor.recommendations) + # Preserve TMDB's recommendation order, keep only in-library hits, and drop the anchor itself. + rks = [ + r["rating_key"] + for r in resolved + if r.get("in_library") and r.get("rating_key") != anchor.rating_key + ] + if not rks: + return None + movies = { + m.rating_key: m + for m in db.execute( + select(PlexItem).where( + PlexItem.kind == "movie", PlexItem.rating_key.in_(rks) + ) + ).scalars().all() + } + shows = { + s.rating_key: s + for s in db.execute( + select(PlexShow).where(PlexShow.rating_key.in_(rks)) + ).scalars().all() + } + ordered_movies = [movies[rk] for rk in rks if rk in movies] + ordered_shows = [shows[rk] for rk in rks if rk in shows] + # rating_key is globally unique in Plex, so it keys the serialized cards back safely (no id()). + mcards = {m.rating_key: c for m, c in zip(ordered_movies, _movie_cards(db, uid, ordered_movies))} + scards = {s.rating_key: c for s, c in zip(ordered_shows, _show_cards(db, uid, ordered_shows))} + items: list[dict] = [] + for rk in rks: + if rk in mcards: + items.append(mcards[rk]) + elif rk in scards: + items.append(scards[rk]) + if not items: + return None + return {"anchor": {"id": anchor.rating_key, "title": anchor.title}, "items": items} + + +@router.get("/discover") +def discover( + scope: str = "both", + lang: str = "en", + 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.""" + lang = _norm_lang(lang) + 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), + } + + +@router.get("/surprise") +def surprise( + scope: str = "both", + limit: int = Query(default=RAIL_LIMIT, ge=1, le=40), + 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.""" + movie_ids, show_ids = _scope_libs(db, scope) + movies = ( + db.execute( + select(PlexItem) + .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_ids)) + .order_by(func.random()) + .limit(limit) + ).scalars().all() + if movie_ids + else [] + ) + shows = ( + db.execute( + select(PlexShow) + .where(PlexShow.library_id.in_(show_ids)) + .order_by(func.random()) + .limit(limit) + ).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`. + items: list[dict] = [] + for i in range(max(len(mcards), len(scards))): + if i < len(mcards): + items.append(mcards[i]) + if i < len(scards): + items.append(scards[i]) + return {"scope": scope, "items": items[:limit]} diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py index d68da63f..6370e373 100644 --- a/backend/app/routes/plex/people.py +++ b/backend/app/routes/plex/people.py @@ -1,24 +1,60 @@ """Plex people: cast/crew filter-chip cards + a single person's filmography page.""" -from fastapi import APIRouter, Depends +import logging +import threading +import time + +from fastapi import APIRouter, BackgroundTasks, Depends from sqlalchemy import func, or_, select from sqlalchemy.orm import Session from app.auth import current_user -from app.db import get_db +from app.db import SessionLocal, get_db from app.models import ( PlexItem, PlexPerson, PlexShow, - PlexState, User, ) from app.plex import rollup +from app.plex import tmdb as plex_tmdb from app.plex.cards import movie_card, show_card from app.plex.query import split_csv from app.routes.plex._shared import _content_libs router = APIRouter(prefix="/api/plex", tags=["plex"]) +log = logging.getLogger("siftlode.plex") + + +def _resolve_known_for( + db: Session, + known_for: list | None, + uid: int, + movie_lib_ids: list[int], + show_lib_ids: list[int], +) -> list[dict]: + """The person's TMDB "Known for" credits (top by popularity) that are IN our library, as cards, in + popularity order. Matched by the canonical guid `tmdb:`; out-of-library credits are dropped.""" + if not known_for: + return [] + # Match each credit to a library row by guid, bucketed by media_type (movie ids and tv ids collide + # numerically) and scoped to the enabled libraries — the shared helper owns that collision-safe + # logic (the detail-page recs use the same one). + movies, shows = plex_tmdb.resolve_library_guids( + db, known_for, movie_lib_ids=movie_lib_ids, show_lib_ids=show_lib_ids + ) + m_states = rollup.item_state_map(db, uid, [m.id for m in movies.values()]) + show_statuses = rollup.state_map(db, uid, [s.id for s in shows.values()]) + out: list[dict] = [] + for k in known_for: + g = f"tmdb:{k.get('tmdb_id')}" + # A credit resolves against its OWN media_type's bucket only. + if k.get("media_type") == "tv": + if g in shows: + out.append(show_card(shows[g], show_statuses.get(shows[g].id, "new"))) + elif g in movies: + out.append(movie_card(movies[g], m_states.get(movies[g].id))) + return out @router.get("/people/cards") @@ -78,9 +114,54 @@ def people_cards( _PERSON_SECTION_MAX = 200 +# A person's bio is enriched at most once per this window: it dedups concurrent opens of the same person +# (each would otherwise schedule its own 8s TMDB task) AND rate-limits re-triggering when TMDB is +# down/slow (a transient failure leaves tmdb_enriched_at NULL, so without this every reload would spawn a +# fresh task and pile up on the shared threadpool). In-process + best-effort: a restart just clears it, at +# worst costing a few extra fetches. A successful enrich stamps the watermark, so the reservation only +# ever expires harmlessly. +_ENRICH_COOLDOWN_S = 60.0 +_enrich_inflight: dict[int, float] = {} +_enrich_lock = threading.Lock() + + +def _should_schedule_enrich(person_id: int) -> bool: + """True (and reserves a cooldown) if this person isn't already being enriched / recently attempted.""" + now = time.monotonic() + with _enrich_lock: + deadline = _enrich_inflight.get(person_id) + if deadline is not None and now < deadline: + return False + _enrich_inflight[person_id] = now + _ENRICH_COOLDOWN_S + # Bound the map: drop expired reservations once it grows (a large library has many people). + if len(_enrich_inflight) > 1024: + for pid in [p for p, d in _enrich_inflight.items() if d <= now]: + del _enrich_inflight[pid] + return True + + +def _enrich_person_bg(person_id: int) -> None: + """Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs + AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's + "no live TMDB call at serve time" invariant). The request's session is already closed by then, so + open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the + next open, and the bio lands from the row by then.""" + db = SessionLocal() + try: + person = db.get(PlexPerson, person_id) + if person is not None: + plex_tmdb.enrich_person(db, person) + except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user + db.rollback() + log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True) + finally: + db.close() + + @router.get("/person/{name}") def person_detail( name: str, + background_tasks: BackgroundTasks, user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: @@ -88,8 +169,24 @@ def person_detail( libraries) whose cast OR directors include this name — the inverse of the cast-name → library-filter link. The name string is the identity key (Plex cast carries no stable per-person id), so distinct people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name - simply returns empty sections.""" - photo = db.scalar(select(PlexPerson.photo_thumb).where(PlexPerson.name == name)) + simply returns empty sections. + + E5 S6: the first time a page is opened, the person's TMDB bio + "Known for" credits are lazily + fetched (from the S2-linked `tmdb_person_id`) and persisted, then served from the row on every open. + The fetch runs OFF the request path (a background task); the first open serves whatever's in the row + (bio possibly still null) and the bio lands from the row by the next open. + """ + person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first() + # Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path. + # Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded) + # can't pile background tasks onto the shared threadpool. + if ( + person is not None + and person.tmdb_person_id + and person.tmdb_enriched_at is None + and _should_schedule_enrich(person.id) + ): + background_tasks.add_task(_enrich_person_bg, person.id) movie_lib_ids, show_lib_ids = _content_libs(db) movies: list[dict] = [] @@ -107,16 +204,7 @@ def person_detail( .order_by(PlexItem.year.desc().nullslast(), func.lower(PlexItem.title)) .limit(_PERSON_SECTION_MAX) ).scalars().all() - item_ids = [it.id for it in items] - states = { - s.item_id: s - for s in db.execute( - select(PlexState).where( - PlexState.user_id == user.id, - PlexState.item_id.in_(item_ids or [0]), - ) - ).scalars() - } + states = rollup.item_state_map(db, user.id, [it.id for it in items]) movies = [movie_card(it, states.get(it.id)) for it in items] shows: list[dict] = [] @@ -136,4 +224,19 @@ def person_detail( statuses = rollup.state_map(db, user.id, [s.id for s in show_rows]) shows = [show_card(sh, statuses.get(sh.id, "new")) for sh in show_rows] - return {"name": name, "photo": photo, "movies": movies, "shows": shows} + known_for = _resolve_known_for( + db, person.known_for if person else None, user.id, movie_lib_ids, show_lib_ids + ) + return { + "name": name, + "photo": person.photo_thumb if person else None, + "biography": person.biography if person else None, + "birthday": person.birthday if person else None, + "deathday": person.deathday if person else None, + "place_of_birth": person.place_of_birth if person else None, + "known_for_department": person.known_for_department if person else None, + "also_known_as": (person.also_known_as if person else None) or [], + "known_for": known_for, + "movies": movies, + "shows": shows, + } diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index 75131d2e..97bdcdd6 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -12,6 +12,7 @@ Ingested results are flagged (videos.via_search / channels.from_search) so the L this search-discovered "noise" by default. Shorts and currently-live/upcoming videos are never ingested this way (Shorts are confirmed by the youtube.com/shorts probe; live/upcoming are dropped from the search response up front).""" +import logging from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone @@ -43,6 +44,8 @@ from app.youtube import search_scrape from app.youtube.client import YouTubeClient, YouTubeError from app.youtube.shorts import make_client, probe_is_short +log = logging.getLogger("siftlode.search") + router = APIRouter(prefix="/api/search", tags=["search"]) # search.list costs this many quota units per page. @@ -136,6 +139,10 @@ def _ingest_candidates( # 2) Video stubs, flagged via_search. Existing rows keep their flags (ON CONFLICT DO # NOTHING), so an organically-synced video stays organic. + # published_at is NOT NULL (migration 0068); the scrape source exposes no publish date + # (only a relative "3 years ago"), so fall back to now() to satisfy the constraint — the + # enrich in step 3 overwrites it with the video's real snippet date. + now = datetime.now(timezone.utc) insert_stubs( db, [ @@ -143,7 +150,7 @@ def _ingest_candidates( "id": it["id"], "channel_id": it["channel_id"], "title": it["title"], - "published_at": parse_dt(it["published_at"]), + "published_at": parse_dt(it["published_at"]) or now, "thumbnail_url": it["thumbnail_url"], "via_search": True, } @@ -158,13 +165,13 @@ def _ingest_candidates( detail_by_id = {it["id"]: it for it in yt.get_videos(ids)} except YouTubeError: detail_by_id = {} - now = datetime.now(timezone.utc) + enriched_at = datetime.now(timezone.utc) # when THIS enrich ran (distinct from the stub placeholder) for v in videos: item = detail_by_id.get(v.id) if item is None: continue apply_video_details(v, item) - v.enriched_at = now + v.enriched_at = enriched_at db.commit() # 4) Confirm/deny Shorts (no quota) so none enter via search. @@ -282,7 +289,19 @@ def search_youtube( and it["channel_id"] and it["channel_id"] not in blocked ] - collected.extend(_ingest_candidates(db, yt, user, candidates)) + # Ingest (DB writes + enrich) runs outside the page-fetch try above; guard it too so a + # write/shape failure degrades to a 422 with a message on this JSON endpoint instead of a + # bare 500 hidden behind the FE's generic "search failed" (same rationale as B-core-1). + try: + collected.extend(_ingest_candidates(db, yt, user, candidates)) + except Exception as exc: # noqa: BLE001 — any ingest failure, not just YouTubeError + db.rollback() + log.exception("live search ingest failed for %r", term) + if collected: + break # keep what earlier pages already gathered + raise HTTPException( + status_code=422, detail=f"YouTube search failed: {exc}" + ) from exc next_cursor = page["next_page_token"] # The API source pages cost 100 units each — never auto-page; one page per request. if source == "api" or len(collected) >= limit or not next_cursor: diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index 1723cfc3..7a8b81b7 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -17,6 +17,7 @@ from app.demo import reset_demo_state from app.downloads.gc import run_download_gc from app.models import SchedulerSetting, User from app.plex.sync import sync as run_plex_sync +from app.plex.tmdb import run_enrich as run_tmdb_enrich from app.plex.watch_sync import run_plex_watch_reconcile, run_plex_watch_sync from app.notifications import create_notification from app.state import is_sync_paused @@ -68,6 +69,7 @@ JOB_INTERVALS: dict[str, int] = { "plex_sync": settings.plex_sync_interval_min, "plex_watch_sync": settings.plex_watch_sync_interval_min, "plex_watch_reconcile": settings.plex_watch_reconcile_interval_min, + "tmdb_enrich": settings.tmdb_enrich_interval_min, "audit_gc": settings.audit_gc_minutes, } @@ -351,6 +353,13 @@ def _plex_watch_reconcile_job() -> None: _job("plex_watch_reconcile", run_plex_watch_reconcile) +def _tmdb_enrich_job() -> None: + # Enrich a batch of mirrored Plex titles from TMDB (keywords/crew/budget/revenue/TMDb score/ + # recommendations/backdrops), matched by guid. External metadata (no YouTube quota); a no-op when + # the TMDB key is unset or the Plex catalog is empty. + _job("tmdb_enrich", run_tmdb_enrich) + + def _audit_gc_job() -> None: # Prune audit-log rows older than the admin-set retention (audit_retention_days; 0 = keep # forever). Pure DB, no quota. @@ -378,6 +387,7 @@ JOB_FUNCS: dict[str, Callable[[], None]] = { "plex_sync": _plex_sync_job, "plex_watch_sync": _plex_watch_sync_job, "plex_watch_reconcile": _plex_watch_reconcile_job, + "tmdb_enrich": _tmdb_enrich_job, "audit_gc": _audit_gc_job, } diff --git a/backend/app/sync/videos.py b/backend/app/sync/videos.py index a36417b9..e4653546 100644 --- a/backend/app/sync/videos.py +++ b/backend/app/sync/videos.py @@ -245,9 +245,15 @@ def apply_video_details(video: Video, item: dict) -> None: video.original_title = raw_title video.title = normalize_title(raw_title) video.description = snippet.get("description") - if not video.published_at: - # NOT NULL (0068): a fresh insert with no date yet falls back to now() (enrich normally has one). - video.published_at = parse_dt(snippet.get("publishedAt")) or _now() + # published_at is NOT NULL (0068). Prefer the enrich snippet's real date whenever it has one — + # this also corrects a now() placeholder written at stub time (e.g. a scrape-search stub, which + # carries no publish date). Only fall back to now() when the row still has nothing at all; never + # clobber an existing real date with now() (a re-enrich whose snippet lacks a date). + snippet_published = parse_dt(snippet.get("publishedAt")) + if snippet_published: + video.published_at = snippet_published + elif not video.published_at: + video.published_at = _now() video.thumbnail_url = best_thumbnail(snippet.get("thumbnails")) or video.thumbnail_url video.duration_seconds = parse_iso8601_duration(content.get("duration")) video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index 13c25d2f..2d94c74c 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -102,6 +102,11 @@ SPECS: tuple[ConfigSpec, ...] = ( ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16), # NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job # (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS. + # --- TMDB enrichment (E5 S2; optional) — the API key is the only secret --- + ConfigSpec("tmdb_api_key", "str", "tmdb", "tmdb_api_key", secret=True), + ConfigSpec("tmdb_language", "str", "tmdb", "tmdb_language"), + # (tmdb_enrich_interval_min is a scheduler job, tuned from the Scheduler dashboard; tmdb_enrich_batch + # is a config.py default — both stay out of SPECS, like plex_sync_interval_min.) ) _BY_KEY: dict[str, ConfigSpec] = {s.key: s for s in SPECS} diff --git a/backend/tests/test_plex_cards.py b/backend/tests/test_plex_cards.py new file mode 100644 index 00000000..8efa25de --- /dev/null +++ b/backend/tests/test_plex_cards.py @@ -0,0 +1,31 @@ +"""Plex card serializers (app.plex.cards) — pure model->dict projections, no DB needed. + +E5 S1: movie/show cards now also emit `art` (the wide backdrop, for a Discover rail's landscape tile +and the crossfading hero), but ONLY when the item actually has an `art_key` — otherwise the URL would +resolve to a blank image on every card that tried to use it, so it must be null and let the client fall +back to the poster.""" + +from app.models import PlexItem, PlexShow +from app.plex.cards import movie_card, show_card + + +def test_movie_card_emits_art_when_art_key_present(): + it = PlexItem(rating_key="m1", kind="movie", title="M", year=2020, art_key="/library/art/1") + card = movie_card(it, None) + assert card["thumb"] == "/api/plex/image/m1" + assert card["art"] == "/api/plex/image/m1?variant=art" + + +def test_movie_card_art_is_none_without_art_key(): + it = PlexItem(rating_key="m2", kind="movie", title="M2") + assert movie_card(it, None)["art"] is None + + +def test_show_card_emits_art_when_art_key_present(): + sh = PlexShow(rating_key="s1", title="S", art_key="/library/art/2") + assert show_card(sh)["art"] == "/api/plex/image/s1?variant=art" + + +def test_show_card_art_is_none_without_art_key(): + sh = PlexShow(rating_key="s2", title="S2") + assert show_card(sh)["art"] is None diff --git a/backend/tests/test_plex_discover.py b/backend/tests/test_plex_discover.py new file mode 100644 index 00000000..bc77d2cd --- /dev/null +++ b/backend/tests/test_plex_discover.py @@ -0,0 +1,202 @@ +"""E5 S4: the Plex Discover rail-surface. GET /api/plex/discover returns the consolidated rail +payload (continue watching / recently added / genre tiles / because-you-watched) served from the DB, +and GET /api/plex/surprise returns a scope-bounded random sample. DB lane (`db` fixture) — skipped +without Postgres. +""" +from datetime import datetime, timedelta, timezone + +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 PlexItem, PlexLibrary, PlexShow, PlexState, User + +NOW = datetime(2024, 6, 1, tzinfo=timezone.utc) + + +def _seed(db): + """A movie + show library. m1 is a watched, TMDB-enriched anchor whose one recommendation is m2 + (in library by guid); m3 is a movie in-progress; sh1 is a show with an in-progress episode.""" + lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([lib_m, lib_s]) + db.flush() + + m1 = PlexItem( + rating_key="m1", library_id=lib_m.id, kind="movie", title="Anchor Movie", + guid="tmdb:111", genres=["Drama"], year=2001, + added_at=NOW - timedelta(days=1), + tmdb_enriched_at=NOW - timedelta(days=1), + recommendations=[ + {"tmdb_id": 555, "title": "Rec Movie", "poster_path": None, "media_type": "movie"} + ], + ) + m2 = PlexItem( + rating_key="m2", library_id=lib_m.id, kind="movie", title="Rec Movie", + guid="tmdb:555", genres=["Comedy"], year=2002, + added_at=NOW - timedelta(days=2), + ) + m3 = PlexItem( + rating_key="m3", library_id=lib_m.id, kind="movie", title="Half-Watched Movie", + guid="tmdb:333", genres=["Drama"], year=2003, duration_s=6000, + added_at=NOW - timedelta(days=3), + ) + sh1 = PlexShow(rating_key="sh1", library_id=lib_s.id, title="A Show", child_count=1, + added_at=NOW - timedelta(hours=1)) # newest → leads recently-added + db.add_all([m1, m2, m3, sh1]) + db.flush() + ep1 = PlexItem( + rating_key="sh1e1", library_id=lib_s.id, kind="episode", title="Ep 1", + show_id=sh1.id, season_number=1, episode_number=1, + ) + ep2 = PlexItem( + rating_key="sh1e2", library_id=lib_s.id, kind="episode", title="Ep 2", + show_id=sh1.id, season_number=1, episode_number=2, + ) + db.add_all([ep1, ep2]) + + user = User(email="discover@example.com") + db.add(user) + db.flush() + + db.add_all([ + # m1 watched most recently → the because-you-watched anchor. + PlexState(user_id=user.id, item_id=m1.id, status="watched", watched_at=NOW), + # m3 in-progress → continue watching. + PlexState(user_id=user.id, item_id=m3.id, status="new", position_seconds=1200, + progress_updated_at=NOW - timedelta(hours=1)), + # sh1's ep1 in-progress → the show is in continue watching. + PlexState(user_id=user.id, item_id=ep1.id, status="new", position_seconds=300, + progress_updated_at=NOW - timedelta(minutes=30)), + ]) + 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: + client = TestClient(app) # no context manager: skip lifespan + return client.get(path) + finally: + app.dependency_overrides.clear() + + +def _ids(cards): + return [c["id"] for c in cards] + + +def test_discover_shape_and_recently_added_order(db, monkeypatch): + user = _seed(db) + resp = _get(db, user, monkeypatch, "/api/plex/discover?scope=both") + assert resp.status_code == 200, resp.text + d = resp.json() + assert set(d) >= {"continue_watching", "recently_added", "genres", "because_you_watched"} + # Recently added is newest-first; the show (added 1d ago) leads, then m1, m2, m3. + assert _ids(d["recently_added"])[:4] == ["sh1", "m1", "m2", "m3"] + # Genre tiles carry counts; Drama (m1 + m3) outweighs Comedy (m2). + genres = {g["value"]: g["count"] for g in d["genres"]} + assert genres.get("Drama") == 2 and genres.get("Comedy") == 1 + + +def test_continue_watching_movies_and_shows(db, monkeypatch): + user = _seed(db) + d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json() + ids = _ids(d["continue_watching"]) + # The in-progress movie and the show with an in-progress episode are both present; the watched + # anchor movie (m1) is not. Order is most-recently-progressed first (the show's ep beats m3). + assert ids == ["sh1", "m3"] + assert "m1" not in ids + + +def test_continue_watching_null_status_in_hidden_out(db, monkeypatch): + # A started movie whose PlexState.status is NULL is still "in progress": `NULL NOT IN (...)` is NULL + # (not TRUE), so a bare notin_ would wrongly drop it — the or_(is_(None), notin_) branch keeps it. + # A hidden started movie must be excluded. + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + m_null = PlexItem(rating_key="mn", library_id=lib.id, kind="movie", title="Null Status", guid="tmdb:1") + m_hidden = PlexItem(rating_key="mh", library_id=lib.id, kind="movie", title="Hidden", guid="tmdb:2") + db.add_all([m_null, m_hidden]) + db.flush() + user = User(email="cw-null@example.com") + db.add(user) + db.flush() + db.add_all([ + PlexState(user_id=user.id, item_id=m_null.id, status=None, position_seconds=500, + progress_updated_at=NOW), + PlexState(user_id=user.id, item_id=m_hidden.id, status="hidden", position_seconds=500, + progress_updated_at=NOW - timedelta(hours=1)), + ]) + db.commit() + + ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=movie").json()["continue_watching"]) + assert "mn" in ids # NULL-status started movie IS in progress + assert "mh" not in ids # hidden is excluded + + +def test_continue_watching_excludes_fully_watched_show(db, monkeypatch): + # A show whose every episode is watched is DONE, not in-progress: the `watched < total` guard must + # keep it off the Continue-watching rail (a bare `watched > 0` would wrongly resurface it). + lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add(lib_s) + db.flush() + sh = PlexShow(rating_key="shw", library_id=lib_s.id, title="All Watched", child_count=1) + db.add(sh) + db.flush() + ep = PlexItem(rating_key="shwe1", library_id=lib_s.id, kind="episode", title="E1", + show_id=sh.id, season_number=1, episode_number=1) + db.add(ep) + db.flush() + user = User(email="fullywatched@example.com") + db.add(user) + db.flush() + db.add(PlexState(user_id=user.id, item_id=ep.id, status="watched", position_seconds=0, + progress_updated_at=NOW)) + db.commit() + + ids = _ids(_get(db, user, monkeypatch, "/api/plex/discover?scope=show").json()["continue_watching"]) + assert "shw" not in ids # fully watched → excluded from continue-watching + + +def test_because_you_watched_resolves_in_library_recs(db, monkeypatch): + user = _seed(db) + d = _get(db, user, monkeypatch, "/api/plex/discover?scope=both").json() + bw = d["because_you_watched"] + assert bw is not None + assert bw["anchor"]["id"] == "m1" and bw["anchor"]["title"] == "Anchor Movie" + # m1's single recommendation (tmdb:555) is m2 in our library → it becomes the one rail card. + assert _ids(bw["items"]) == ["m2"] + + +def test_because_you_watched_null_without_history(db, monkeypatch): + _seed(db) + fresh = User(email="fresh-discover@example.com") + db.add(fresh) + db.commit() + d = _get(db, fresh, monkeypatch, "/api/plex/discover?scope=both").json() + # A user with no watched-and-enriched movie gets no recommendation rail. + assert d["because_you_watched"] is None + assert d["continue_watching"] == [] + + +def test_surprise_scope_and_bound(db, monkeypatch): + user = _seed(db) + resp = _get(db, user, monkeypatch, "/api/plex/surprise?scope=movie&limit=2") + assert resp.status_code == 200, resp.text + items = resp.json()["items"] + assert len(items) <= 2 + assert all(c["type"] == "movie" for c in items) # scope=movie excludes shows + + +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"] diff --git a/backend/tests/test_plex_facets.py b/backend/tests/test_plex_facets.py index fe8198b5..dd7e050a 100644 --- a/backend/tests/test_plex_facets.py +++ b/backend/tests/test_plex_facets.py @@ -12,7 +12,33 @@ 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 PlexLibrary, PlexShow, User +from app.models import PlexItem, PlexLibrary, PlexShow, User + + +def test_facets_returns_studio_vocabulary(db, monkeypatch): + """E5 S7: /facets returns the studio vocabulary (distinct studio + count) for the sidebar picker.""" + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([ + PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="A", studio="Pixar"), + PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="B", studio="Pixar"), + PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="C", studio="A24"), + PlexItem(rating_key="m4", library_id=lib.id, kind="movie", title="D"), # no studio + ]) + user = User(email="studiofacets@example.com") + db.add(user) + db.commit() + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + resp = TestClient(app).get("/api/plex/facets?scope=movie") + finally: + app.dependency_overrides.clear() + assert resp.status_code == 200, resp.text + studios = {s["value"]: s["count"] for s in resp.json()["studios"]} + assert studios == {"Pixar": 2, "A24": 1} def test_facets_survives_json_null_genres(db, monkeypatch): diff --git a/backend/tests/test_plex_keyword_filter.py b/backend/tests/test_plex_keyword_filter.py new file mode 100644 index 00000000..05016887 --- /dev/null +++ b/backend/tests/test_plex_keyword_filter.py @@ -0,0 +1,61 @@ +"""E5 S5: the TMDB keyword filter. A keyword chip on the detail page sets ?keywords=, which +narrows the unified library to rows whose persisted `keywords` JSONB array contains it. 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 PlexItem, PlexLibrary, User + + +def _seed(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([ + PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Heist Movie", + keywords=["heist", "bank robbery"]), + PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Space Movie", + keywords=["space", "aliens"]), + PlexItem(rating_key="m3", library_id=lib.id, kind="movie", title="No Keywords Movie"), + ]) + user = User(email="kwfilter@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_keyword_filter_narrows_to_matching_rows(db, monkeypatch): + user = _seed(db) + resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=heist") + assert resp.status_code == 200, resp.text + assert _titles(resp) == {"Heist Movie"} + + +def test_no_keyword_filter_returns_all(db, monkeypatch): + user = _seed(db) + resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie") + assert _titles(resp) == {"Heist Movie", "Space Movie", "No Keywords Movie"} + + +def test_unknown_keyword_matches_nothing(db, monkeypatch): + user = _seed(db) + resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&keywords=nonesuch") + assert resp.json()["items"] == [] diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py index 0caefac7..4709074d 100644 --- a/backend/tests/test_plex_person.py +++ b/backend/tests/test_plex_person.py @@ -100,7 +100,20 @@ def test_person_detail_unknown_name_is_empty(db, monkeypatch): resp = _get(db, user, monkeypatch, "/api/plex/person/Ghost%20Actor") assert resp.status_code == 200, resp.text body = resp.json() - assert body == {"name": "Ghost Actor", "photo": None, "movies": [], "shows": []} + # An unknown name has no plex_people row → no bio, no titles (E5 S6 added the bio fields, all null). + assert body == { + "name": "Ghost Actor", + "photo": None, + "biography": None, + "birthday": None, + "deathday": None, + "place_of_birth": None, + "known_for_department": None, + "also_known_as": [], + "known_for": [], + "movies": [], + "shows": [], + } def test_content_libs_partitions_only_enabled(db): diff --git a/backend/tests/test_plex_person_bio.py b/backend/tests/test_plex_person_bio.py new file mode 100644 index 00000000..329a79c5 --- /dev/null +++ b/backend/tests/test_plex_person_bio.py @@ -0,0 +1,292 @@ +"""E5 S6: person page TMDB bio enrichment. `parse_person` shapes the /person payload (bio + a +popularity-ranked, deduped `known_for`); `enrich_person` lazily fetches + persists once (guarded on a +key + a tmdb_person_id); and /person/{name} serves the bio + resolves `known_for` to library rows. +DB lane for the endpoint/enrich tests; parse_person is pure. Skipped without Postgres where DB-backed. +""" +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 PlexItem, PlexLibrary, PlexPerson, User +from app.plex import tmdb as plex_tmdb + +PAYLOAD = { + "biography": " A celebrated actor. ", + "birthday": "1970-01-01", + "deathday": None, + "place_of_birth": "Springfield", + "known_for_department": "Acting", + "also_known_as": ["AKA One", "AKA Two"], + "combined_credits": { + "cast": [ + {"id": 10, "media_type": "movie", "title": "Mid Movie", "popularity": 50}, + {"id": 20, "media_type": "tv", "name": "Top Show", "popularity": 80}, + {"id": 10, "media_type": "movie", "title": "dup", "popularity": 5}, # dup id + {"id": 30, "media_type": "movie", "title": "Low Movie", "popularity": 1}, + {"id": None, "media_type": "movie", "title": "No id", "popularity": 99}, # dropped + ] + }, +} + + +def test_parse_person_shapes_bio_and_ranked_known_for(): + p = plex_tmdb.parse_person(PAYLOAD) + assert p["biography"] == "A celebrated actor." # stripped + assert p["birthday"] == "1970-01-01" + assert p["known_for_department"] == "Acting" + assert p["also_known_as"] == ["AKA One", "AKA Two"] + # Popularity desc, deduped by id, None-id dropped: Top Show (80) → Mid Movie (50) → Low Movie (1). + assert [k["tmdb_id"] for k in p["known_for"]] == [20, 10, 30] + assert p["known_for"][0]["media_type"] == "tv" + + +def test_parse_person_empty_payload(): + p = plex_tmdb.parse_person({}) + assert p["biography"] is None and p["known_for"] is None + + +def test_parse_person_keeps_same_id_across_media_types(): + # TMDB movie ids and tv ids are independent, so a movie-42 and a tv-42 credit are two distinct works + # and BOTH must survive the dedup (keyed on (id, media_type), not the bare id). + payload = { + "combined_credits": { + "cast": [ + {"id": 42, "media_type": "movie", "title": "Movie 42", "popularity": 90}, + {"id": 42, "media_type": "tv", "name": "Show 42", "popularity": 80}, + {"id": 42, "media_type": "movie", "title": "dup movie 42", "popularity": 5}, # real dup + ] + } + } + kf = plex_tmdb.parse_person(payload)["known_for"] + assert [(k["tmdb_id"], k["media_type"]) for k in kf] == [(42, "movie"), (42, "tv")] # both kept, dup dropped + + +class _FakeTmdb: + def __init__(self, payload): + self.payload = payload + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fetch_person(self, pid): + return self.payload + + +def test_enrich_person_persists_and_stamps(db, monkeypatch): + person = PlexPerson(name="Actor X", tmdb_person_id=555) + db.add(person) + db.commit() + monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "") + monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdb(PAYLOAD)) + assert plex_tmdb.enrich_person(db, person) is True + assert person.biography == "A celebrated actor." + assert person.tmdb_enriched_at is not None + assert [k["tmdb_id"] for k in person.known_for] == [20, 10, 30] + + +class _FakeTmdbResult: + """Context-manager TmdbClient stand-in whose fetch_person raises / returns a fixed payload.""" + + def __init__(self, *, raises=False, payload=None): + self._raises = raises + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def fetch_person(self, pid): + if self._raises: + raise plex_tmdb.TmdbError("429 rate limited") + return self._payload + + +def test_enrich_person_transient_error_leaves_watermark_null(db, monkeypatch): + # A transient TmdbError must NOT stamp the watermark, so the next open retries (never permanently + # blank a person after one blip). + person = PlexPerson(name="Transient P", tmdb_person_id=555) + db.add(person) + db.commit() + monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "") + monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(raises=True)) + assert plex_tmdb.enrich_person(db, person) is False + assert person.tmdb_enriched_at is None # left NULL → retried next open + + +def test_enrich_person_404_stamps_watermark_without_bio(db, monkeypatch): + # A 404 (deleted person → None payload) stamps the watermark so it isn't refetched every open. + person = PlexPerson(name="Gone P", tmdb_person_id=555) + db.add(person) + db.commit() + monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key" if k == "tmdb_api_key" else "") + monkeypatch.setattr(plex_tmdb, "TmdbClient", lambda d, timeout=None: _FakeTmdbResult(payload=None)) + assert plex_tmdb.enrich_person(db, person) is True + assert person.tmdb_enriched_at is not None # stamped + assert person.biography is None # nothing fetched + + +def test_enrich_person_noop_without_key(db, monkeypatch): + person = PlexPerson(name="Actor Y", tmdb_person_id=555) + db.add(person) + db.commit() + monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "") # no key + assert plex_tmdb.enrich_person(db, person) is False + assert person.tmdb_enriched_at is None + + +def test_enrich_person_noop_without_tmdb_id(db, monkeypatch): + person = PlexPerson(name="Actor Z", tmdb_person_id=None) + db.add(person) + db.commit() + monkeypatch.setattr(plex_tmdb.sysconfig, "get_str", lambda d, k: "key") + assert plex_tmdb.enrich_person(db, person) is False + + +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 test_person_detail_schedules_enrich_off_request_path(db, monkeypatch): + # The bio fetch runs OFF the request path (a background task after the response), so the serve never + # makes a live TMDB call. An un-enriched person (tmdb_person_id set + tmdb_enriched_at None) schedules + # the enrich and the page still renders immediately with whatever's in the row (bio still null). + from app.routes.plex import people + + lib = PlexLibrary(plex_key="9", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add(PlexPerson(name="Lazy P", tmdb_person_id=777)) + user = User(email="lazybg@example.com") + db.add(user) + db.commit() + + scheduled: list[int] = [] + monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) + people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open + + resp = _get(db, user, monkeypatch, "/api/plex/person/Lazy P") + assert resp.status_code == 200, resp.text + assert resp.json()["biography"] is None # not enriched yet — the bio lands by the next open + assert len(scheduled) == 1 # the enrich was scheduled off the request path + + +def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch): + # The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling + # back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed). + from app.routes.plex import people + + calls = {"rollback": 0, "close": 0, "enriched": 0} + + class _FakeSession: + def __init__(self, person): + self._person = person + + def get(self, model, pid): + return self._person + + def rollback(self): + calls["rollback"] += 1 + + def close(self): + calls["close"] += 1 + + # Success path: person found → enrich called, session closed, no rollback. + monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object())) + monkeypatch.setattr( + people.plex_tmdb, "enrich_person", lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1) + ) + people._enrich_person_bg(5) + assert calls == {"rollback": 0, "close": 1, "enriched": 1} + + # Error path: enrich raises → rollback + close, no exception escapes. + calls.update({"rollback": 0, "close": 0, "enriched": 0}) + + def _boom(db, p): + raise RuntimeError("enrich blew up") + + monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom) + people._enrich_person_bg(5) # must not raise + assert calls["rollback"] == 1 and calls["close"] == 1 + + # Missing person (deleted id): None-guard skips enrich, session still closed. + calls.update({"rollback": 0, "close": 0, "enriched": 0}) + monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None)) + people._enrich_person_bg(9) + assert calls == {"rollback": 0, "close": 1, "enriched": 0} + + +def test_should_schedule_enrich_dedups_within_cooldown(): + # Concurrent/rapid opens of the same person must schedule the enrich only once per cooldown window. + from app.routes.plex import people + + people._enrich_inflight.clear() + assert people._should_schedule_enrich(12345) is True # first open reserves + assert people._should_schedule_enrich(12345) is False # within cooldown → deduped + assert people._should_schedule_enrich(99999) is True # a different person is independent + people._enrich_inflight.clear() + + +def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch): + # An already-enriched person (tmdb_enriched_at set) must NOT schedule another background fetch. + from datetime import datetime, timezone + + from app.routes.plex import people + + lib = PlexLibrary(plex_key="8", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add(PlexPerson(name="Done P", tmdb_person_id=888, + tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), biography="Bio.")) + user = User(email="donebg@example.com") + db.add(user) + db.commit() + + scheduled: list[int] = [] + monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) + + resp = _get(db, user, monkeypatch, "/api/plex/person/Done P") + assert resp.status_code == 200, resp.text + assert resp.json()["biography"] == "Bio." + assert scheduled == [] # already enriched → no background work + + +def test_person_detail_serves_bio_and_resolves_known_for(db, monkeypatch): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + # A library movie that IS one of the person's known_for credits (matched by guid tmdb:100). + db.add(PlexItem(rating_key="km", library_id=lib.id, kind="movie", title="Known Movie", + guid="tmdb:100", cast_names=["Actor X"])) + # Already-enriched person (tmdb_enriched_at set → no lazy fetch), with a known_for pool. + from datetime import datetime, timezone + db.add(PlexPerson(name="Actor X", tmdb_person_id=555, + tmdb_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + biography="Bio here.", birthday="1970-01-01", known_for_department="Acting", + also_known_as=["Alt Name"], + known_for=[{"tmdb_id": 100, "media_type": "movie", "title": "Known Movie"}, + {"tmdb_id": 999, "media_type": "movie", "title": "Not In Library"}])) + user = User(email="personbio@example.com") + db.add(user) + db.commit() + + resp = _get(db, user, monkeypatch, "/api/plex/person/Actor X") + assert resp.status_code == 200, resp.text + j = resp.json() + assert j["biography"] == "Bio here." + assert j["birthday"] == "1970-01-01" + assert j["also_known_as"] == ["Alt Name"] + # Only the in-library known_for credit resolves to a card; the out-of-library one is dropped. + assert [c["title"] for c in j["known_for"]] == ["Known Movie"] diff --git a/backend/tests/test_plex_tmdb.py b/backend/tests/test_plex_tmdb.py new file mode 100644 index 00000000..0adca455 --- /dev/null +++ b/backend/tests/test_plex_tmdb.py @@ -0,0 +1,365 @@ +"""TMDB enrichment engine (E5 S2): the pure parser + persister, guid→tmdb-id resolution, and the +DB-lane batch/serve integration (enrich stamps the watermark + links people; serve_block resolves +recommendations to library rows; the whole thing no-ops without an API key). + +The DB tests use the `db` fixture (real Postgres) — skipped when none is reachable.""" +from datetime import datetime, timezone + +import httpx +import pytest + +from app.models import PlexItem, PlexLibrary, PlexPerson, PlexShow +from app.plex import tmdb + +# A TMDB /movie/{id}?append_to_response=keywords,credits,recommendations,images,external_ids payload. +MOVIE_PAYLOAD = { + "id": 238, + "vote_average": 8.7, + "vote_count": 20000, + "budget": 6000000, + "revenue": 245066411, + "keywords": {"keywords": [{"id": 1, "name": "mafia"}, {"id": 2, "name": "crime family"}]}, + "credits": { + "cast": [ + {"id": 3084, "name": "Marlon Brando", "character": "Vito", "profile_path": "/a.jpg", "order": 0}, + {"id": 1158, "name": "Al Pacino", "character": "Michael", "profile_path": "/b.jpg", "order": 1}, + ], + "crew": [ + {"id": 1776, "name": "Francis Ford Coppola", "job": "Director", "profile_path": "/c.jpg"}, + {"id": 1776, "name": "Francis Ford Coppola", "job": "Writer", "profile_path": "/c.jpg"}, + {"id": 9, "name": "Some Gaffer", "job": "Gaffer"}, # not a key job → dropped + ], + }, + "production_companies": [{"name": "Paramount"}, {"name": "Alfran"}], + "production_countries": [{"name": "United States of America"}], + "recommendations": { + "results": [ + {"id": 240, "title": "The Godfather Part II", "poster_path": "/p.jpg", "media_type": "movie"}, + {"id": 999, "title": "Not In Library", "poster_path": "/q.jpg", "media_type": "movie"}, + ] + }, + "images": {"backdrops": [{"file_path": "/bd1.jpg"}, {"file_path": "/bd2.jpg"}]}, + "external_ids": {"imdb_id": "tt0068646"}, +} + +# A TMDB /tv payload: keywords under `results`, aggregate_credits with per-person `jobs`, no budget. +TV_PAYLOAD = { + "id": 1396, + "vote_average": 9.0, + "vote_count": 12000, + "created_by": [{"id": 66633, "name": "Vince Gilligan", "profile_path": "/vg.jpg"}], + "keywords": {"results": [{"name": "drugs"}, {"name": "new mexico"}]}, + "aggregate_credits": { + "cast": [{"id": 17419, "name": "Bryan Cranston"}], + "crew": [ + {"id": 66633, "name": "Vince Gilligan", "jobs": [{"job": "Executive Producer"}]}, + {"id": 5, "name": "Someone", "jobs": [{"job": "Sound"}]}, # dropped + ], + }, + "production_companies": [{"name": "Sony Pictures Television"}], + "production_countries": [{"name": "United States of America"}], + "recommendations": {"results": []}, + "images": {"backdrops": []}, +} + + +class FakeTmdb: + """Stand-in for TmdbClient in the parse/batch tests — no network.""" + + def __init__(self, payloads=None, find_map=None): + self.payloads = payloads or {} # (media_type, tmdb_id) -> payload + self.find_map = find_map or {} # external_id -> /find result + + def fetch(self, tmdb_id, media_type): + return self.payloads.get((media_type, tmdb_id)) + + def find(self, external_id, source): + return self.find_map.get(external_id) + + +# ---- pure parse/persist -------------------------------------------------------------------------- + +def test_parse_movie_pulls_all_fields(): + d = tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie") + assert d["tmdb_id"] == 238 + assert d["tmdb_rating"] == 8.7 + assert d["tmdb_vote_count"] == 20000 + assert d["budget"] == 6000000 + assert d["revenue"] == 245066411 + assert d["keywords"] == ["mafia", "crime family"] + assert d["crew"] == [ + {"name": "Francis Ford Coppola", "job": "Director", "tmdb_id": 1776, "profile_path": "/c.jpg"}, + {"name": "Francis Ford Coppola", "job": "Writer", "tmdb_id": 1776, "profile_path": "/c.jpg"}, + ] # the Gaffer is filtered; the two Coppola jobs survive (dedup is by name+job) + assert d["studios"] == ["Paramount", "Alfran"] + assert d["production_countries"] == ["United States of America"] + assert [r["tmdb_id"] for r in d["recommendations"]] == [240, 999] + assert d["backdrops"] == ["/bd1.jpg", "/bd2.jpg"] + # cast + crew names → tmdb person ids for the plex_people foundation + links = {(p["name"], p["tmdb_id"]) for p in d["people"]} + assert ("Marlon Brando", 3084) in links + assert ("Al Pacino", 1158) in links + assert ("Francis Ford Coppola", 1776) in links + + +def test_parse_tv_uses_aggregate_credits_and_no_money(): + d = tmdb.parse_tmdb(TV_PAYLOAD, "tv") + assert d["budget"] is None and d["revenue"] is None # TV has neither + assert d["keywords"] == ["drugs", "new mexico"] # from keywords.results + # The showrunner (created_by → Creator) leads; the aggregate-credits EP follows; the Sound person is + # filtered; TV jobs are read from jobs[0]. Crew is display-priority ordered (Creator before EP). + assert d["crew"] == [ + {"name": "Vince Gilligan", "job": "Creator", "tmdb_id": 66633, "profile_path": "/vg.jpg"}, + {"name": "Vince Gilligan", "job": "Executive Producer", "tmdb_id": 66633, "profile_path": None}, + ] + assert d["tmdb_rating"] == 9.0 + assert ("Bryan Cranston", 17419) in {(p["name"], p["tmdb_id"]) for p in d["people"]} + + +def test_parse_zero_votes_and_money_are_unknown(): + d = tmdb.parse_tmdb({"id": 1, "vote_average": 0.0, "vote_count": 0, "budget": 0, "revenue": 0}, "movie") + assert d["tmdb_rating"] is None # 0.0 with no votes is meaningless + assert d["tmdb_vote_count"] is None + assert d["budget"] is None and d["revenue"] is None + + +def test_apply_writes_row_fields_without_touching_plex_owned(): + it = PlexItem(rating_key="m1", kind="movie", title="The Godfather", imdb_rating=8.7, full_cast=[{"x": 1}]) + tmdb.apply_tmdb_detail(it, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie")) + assert it.tmdb_id == 238 + assert it.budget == 6000000 + assert it.keywords == ["mafia", "crime family"] + assert it.imdb_rating == 8.7 and it.full_cast == [{"x": 1}] # Plex-owned, untouched + + +def test_guid_to_tmdb_id_direct_find_and_bad(): + fake = FakeTmdb(find_map={"tt0068646": {"movie_results": [{"id": 238}], "tv_results": []}}) + assert tmdb.guid_to_tmdb_id("tmdb:238", "movie", fake) == 238 + assert tmdb.guid_to_tmdb_id("imdb:tt0068646", "movie", fake) == 238 # resolved via /find + assert tmdb.guid_to_tmdb_id("imdb:tt0000000", "movie", fake) is None # not in find_map + assert tmdb.guid_to_tmdb_id("tmdb:notanumber", "movie", fake) is None + assert tmdb.guid_to_tmdb_id(None, "movie", fake) is None + assert tmdb.guid_to_tmdb_id("local://abc", "movie", fake) is None + + +def test_guid_to_tmdb_id_tvdb_source_and_tv_media_type(): + # A tvdb guid for a TV item goes through /find and must pick the tv_results list (not movie_results), + # and a tvdb source maps to the tvdb_id external_source. The media_type branch is the whole point. + fake = FakeTmdb(find_map={"81189": {"movie_results": [], "tv_results": [{"id": 1396}]}}) + assert tmdb.guid_to_tmdb_id("tvdb:81189", "tv", fake) == 1396 + # The SAME /find result, asked for a movie, reads the (empty) movie_results → None. + assert tmdb.guid_to_tmdb_id("tvdb:81189", "movie", fake) is None + # A direct tmdb guid for a tv item is just the int (no /find). + assert tmdb.guid_to_tmdb_id("tmdb:1396", "tv", fake) == 1396 + # An unknown provider (only imdb/tvdb are /find sources) is unresolvable. + assert tmdb.guid_to_tmdb_id("plex:abc", "tv", fake) is None + + +# ---- DB-lane: batch enrich + serve -------------------------------------------------------------- + +def _lib(db, key="1", kind="movie"): + lib = PlexLibrary(plex_key=key, title="L", kind=kind, enabled=True) + db.add(lib) + db.flush() + return lib + + +def test_enrich_batch_persists_stamps_and_links_people(db): + lib = _lib(db) + mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238") + unmatched = PlexItem(rating_key="m2", library_id=lib.id, kind="movie", title="Nope", guid="tmdb:777") + db.add_all([mv, unmatched]) + db.commit() + + fake = FakeTmdb(payloads={("movie", 238): MOVIE_PAYLOAD}) # 777 has no payload → definitive no-match + n = tmdb.enrich_batch(db, fake, 50) + assert n == 2 # both processed (one matched, one definitively unmatched) + + db.refresh(mv) + assert mv.tmdb_id == 238 + assert mv.keywords == ["mafia", "crime family"] + assert mv.revenue == 245066411 + assert mv.tmdb_enriched_at is not None + + db.refresh(unmatched) + assert unmatched.tmdb_enriched_at is not None # stamped so it isn't re-fetched every run + assert unmatched.tmdb_id is None # but nothing stored + + brando = db.execute( + PlexPerson.__table__.select().where(PlexPerson.name == "Marlon Brando") + ).mappings().first() + assert brando is not None and brando["tmdb_person_id"] == 3084 + + +def test_serve_block_none_until_enriched_and_resolves_recs(db): + lib = _lib(db) + # A recommendation target that IS in our library (guid tmdb:240) → deep-linkable. + sequel = PlexItem(rating_key="m240", library_id=lib.id, kind="movie", title="Part II", guid="tmdb:240") + fresh = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", guid="tmdb:238") + db.add_all([sequel, fresh]) + db.commit() + + assert tmdb.serve_block(db, fresh) is None # not enriched yet + + tmdb.apply_tmdb_detail(fresh, tmdb.parse_tmdb(MOVIE_PAYLOAD, "movie")) + fresh.tmdb_enriched_at = datetime.now(timezone.utc) + db.commit() + + block = tmdb.serve_block(db, fresh) + assert block is not None + assert block["budget"] == 6000000 + assert block["keywords"] == ["mafia", "crime family"] + recs = {r["tmdb_id"]: r for r in block["recommendations"]} + assert recs[240]["in_library"] is True and recs[240]["rating_key"] == "m240" + assert recs[999]["in_library"] is False and recs[999]["rating_key"] is None + + +def test_get_never_leaks_api_key_in_error_messages(monkeypatch): + # The api_key rides in the request URL query string, so an error message built from the httpx + # exception (which embeds that URL) would leak the secret to logs (enrich_batch logs it). Both the + # HTTP-status and the network error paths must report status/path only — never the key. + monkeypatch.setattr( + tmdb.sysconfig, "get_str", lambda d, k: "SECRETKEY" if k == "tmdb_api_key" else "en-US" + ) + client = tmdb.TmdbClient(None) + req = httpx.Request( + "GET", "https://api.themoviedb.org/3/movie/1?api_key=SECRETKEY&language=en-US" + ) + + class _StatusHttp: + def get(self, url, params=None): + return httpx.Response(401, request=req) # non-404 → raise_for_status → HTTPStatusError + + def close(self): + pass + + client._http = _StatusHttp() + with pytest.raises(tmdb.TmdbError) as ei: + client._get("/movie/1") + assert "SECRETKEY" not in str(ei.value) and "401" in str(ei.value) + + class _NetHttp: + def get(self, url, params=None): + raise httpx.ConnectError("connect failed", request=req) + + def close(self): + pass + + client._http = _NetHttp() + with pytest.raises(tmdb.TmdbError) as ei2: + client._get("/movie/1") + assert "SECRETKEY" not in str(ei2.value) + client.close() + + +def test_run_enrich_noops_without_key(db): + # No tmdb_api_key configured (default "") → clean no-op, never an error. + out = tmdb.run_enrich(db) + assert out["enriched"] == 0 and "skipped" in out + + +class _RaisingTmdb: + """A client whose fetch raises a transient TmdbError (e.g. a 429), to exercise the retry path.""" + + def find(self, external_id, source): + return None + + def fetch(self, tmdb_id, media_type): + raise tmdb.TmdbError("429 rate limited") + + +def test_enrich_batch_transient_error_leaves_watermark_null(db): + # A transient TmdbError mid-batch must leave the row's tmdb_enriched_at NULL so the next run retries + # it (unlike a definitive no-match, which stamps the watermark). The row is NOT counted as processed. + lib = _lib(db) + mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Godfather", guid="tmdb:238") + db.add(mv) + db.commit() + + n = tmdb.enrich_batch(db, _RaisingTmdb(), 50) + assert n == 0 # the transient failure is skipped, not processed + db.refresh(mv) + assert mv.tmdb_enriched_at is None # left NULL → retried next run + assert mv.tmdb_id is None + + +def test_enrich_batch_skips_shows_without_guid(db): + lib = _lib(db, key="2", kind="show") + sh = PlexShow(rating_key="s1", library_id=lib.id, title="No Guid Show") # guid None → not selected + db.add(sh) + db.commit() + assert tmdb.enrich_batch(db, FakeTmdb(), 50) == 0 + db.refresh(sh) + assert sh.tmdb_enriched_at is None + + +def test_resolve_library_guids_scopes_to_given_libraries(db): + # The scoped path (person page) must keep a credit that lives in a NON-scoped library (disabled or an + # HU-overlay twin) OUT of the result; the unscoped path (detail recs) matches across all libraries; + # and an explicit empty list skips that media side entirely (distinct from None = match all). + lib_on = _lib(db, key="on", kind="movie") + lib_off = _lib(db, key="off", kind="movie") + m_on = PlexItem(rating_key="on1", library_id=lib_on.id, kind="movie", title="In Scope", guid="tmdb:100") + m_off = PlexItem(rating_key="off1", library_id=lib_off.id, kind="movie", title="Out", guid="tmdb:200") + db.add_all([m_on, m_off]) + db.commit() + entries = [{"tmdb_id": 100, "media_type": "movie"}, {"tmdb_id": 200, "media_type": "movie"}] + + # Scoped to lib_on: the row in lib_off is excluded. + movies, shows = tmdb.resolve_library_guids( + db, entries, movie_lib_ids=[lib_on.id], show_lib_ids=[] + ) + assert set(movies) == {"tmdb:100"} + assert shows == {} + + # Unscoped (None defaults): matches across all libraries. + movies_all, _ = tmdb.resolve_library_guids(db, entries) + assert set(movies_all) == {"tmdb:100", "tmdb:200"} + + # Empty list for a media side skips its bucket even with candidates present (vs None = match all). + _, shows_empty = tmdb.resolve_library_guids( + db, [{"tmdb_id": 100, "media_type": "tv"}], show_lib_ids=[] + ) + assert shows_empty == {} + + +def test_resolve_recs_matches_by_media_type_not_bare_id(db): + # TMDB movie ids and tv ids are independent, so tmdb:500 can be BOTH a show (in our library) and a + # movie recommendation. The match must respect media_type — a bare-id match would deep-link the movie + # rec to the unrelated show (and vice-versa). + mlib = _lib(db, key="10", kind="movie") + slib = _lib(db, key="11", kind="show") + show500 = PlexShow(rating_key="s500", library_id=slib.id, title="Show 500", guid="tmdb:500") + movie900 = PlexItem( + rating_key="m900", library_id=mlib.id, kind="movie", title="Movie 900", guid="tmdb:900" + ) + db.add_all([show500, movie900]) + db.commit() + + # A movie rec for id 500 must NOT match show s500; a tv rec for id 900 must NOT match movie m900. + miss = { + r["tmdb_id"]: r + for r in tmdb.resolve_recs( + db, + [ + {"tmdb_id": 500, "title": "Movie 500", "media_type": "movie"}, + {"tmdb_id": 900, "title": "Show 900", "media_type": "tv"}, + ], + ) + } + assert miss[500]["in_library"] is False and miss[500]["rating_key"] is None + assert miss[900]["in_library"] is False and miss[900]["rating_key"] is None + + # The right media_type DOES resolve to the right row. + hit = { + r["tmdb_id"]: r + for r in tmdb.resolve_recs( + db, + [ + {"tmdb_id": 900, "media_type": "movie"}, + {"tmdb_id": 500, "media_type": "tv"}, + ], + ) + } + assert hit[900]["in_library"] is True and hit[900]["rating_key"] == "m900" + assert hit[500]["in_library"] is True and hit[500]["rating_key"] == "s500" diff --git a/backend/tests/test_search_scrape_ingest.py b/backend/tests/test_search_scrape_ingest.py new file mode 100644 index 00000000..9b840969 --- /dev/null +++ b/backend/tests/test_search_scrape_ingest.py @@ -0,0 +1,155 @@ +"""Regression: live YouTube search ingest must survive the scrape source carrying no publish date. + +`videos.published_at` is NOT NULL (migration 0068, PERF-15). The scrape source exposes only a +relative time ("3 years ago"), so its stub has `published_at=None`; the ingest used to pass that +None straight through, which the NOT NULL constraint rejected with an IntegrityError. That error +is raised OUTSIDE the route's page-fetch try/except, so the whole `/api/search/youtube` request +500'd and the UI showed the generic "YouTube search failed." — every scrape search was broken. + +The stub now falls back to now() (a non-null placeholder), and the enrich overwrites it with the +video's real snippet date. These DB-lane tests lock both halves: no crash on a date-less stub, and +a placeholder never becomes the video's permanent date. +""" +from datetime import datetime, timezone + +from app import sysconfig +from app.models import User, Video +from app.routes.search import _ingest_candidates + + +class _FakeYT: + """Offline stand-in for YouTubeClient. Channel enrich is a no-op (the from_search stub survives); + video enrich returns a real publishedAt plus a long duration so Shorts classification finalises + without the youtube.com/shorts probe (no network in the DB lane).""" + + def __init__(self, enrich: dict[str, dict]): + self._enrich = enrich + + def get_channels(self, ids): + return [] + + def get_videos(self, ids): + return [self._enrich[i] for i in ids if i in self._enrich] + + +def _candidate(vid: str, ch: str) -> dict: + # The scrape shape (see search_scrape._video_item): published_at is ALWAYS None. + return { + "id": vid, + "channel_id": ch, + "channel_title": f"Chan {ch}", + "title": f"Title {vid}", + "published_at": None, + "thumbnail_url": None, + "live_broadcast": "none", + } + + +def _enrich_item(vid: str, published_iso: str) -> dict: + # PT30M keeps the video well above shorts_probe_max_seconds so no probe fires. + return { + "id": vid, + "snippet": {"title": f"Title {vid}", "publishedAt": published_iso}, + "contentDetails": {"duration": "PT30M"}, + "statistics": {}, + "status": {}, + } + + +def test_scrape_ingest_dateless_stub_ingests_and_takes_real_enrich_date(db): + # Small cap so the 1800s enrich duration keeps the shorts step offline. + sysconfig.set_value(db, "shorts_probe_max_seconds", 60) + user = User(email="searcher@example.com") + db.add(user) + db.commit() + + candidates = [_candidate("vid_aaaaaaa", "UC_chan_a"), _candidate("vid_bbbbbbb", "UC_chan_a")] + enrich = { + "vid_aaaaaaa": _enrich_item("vid_aaaaaaa", "2021-02-12T19:00:07Z"), + "vid_bbbbbbb": _enrich_item("vid_bbbbbbb", "2023-10-30T17:00:08Z"), + } + + kept = _ingest_candidates(db, _FakeYT(enrich), user, candidates) + + # No IntegrityError, and both non-Short/non-live videos are kept in input order. + assert kept == ["vid_aaaaaaa", "vid_bbbbbbb"] + a = db.get(Video, "vid_aaaaaaa") + # The now() placeholder was replaced by the video's real snippet date, not left as "now". + assert a.published_at == datetime(2021, 2, 12, 19, 0, 7, tzinfo=timezone.utc) + assert a.via_search is True + assert a.enriched_at is not None + + +def test_scrape_ingest_enrich_miss_keeps_nonnull_placeholder(db): + # If enrich yields nothing (proxy blip / omitted row), the stub must STILL satisfy the NOT NULL + # constraint (a non-null placeholder) — it simply isn't kept (enriched_at stays None). + user = User(email="searcher2@example.com") + db.add(user) + db.commit() + + kept = _ingest_candidates(db, _FakeYT({}), user, [_candidate("vid_ccccccc", "UC_chan_c")]) + + assert kept == [] + c = db.get(Video, "vid_ccccccc") + assert c.published_at is not None # placeholder wrote a real value → no crash + assert c.enriched_at is None + + +def test_search_youtube_route_returns_422_when_ingest_fails(db, monkeypatch): + # Route-level guard (B-core-1): an ingest failure with nothing yet collected must surface as a 422 + # + message on this JSON endpoint (the FE hides 5xx behind a generic "connection lost" + auto-retry, + # burning a second quota-costed search), and the session is rolled back. Here the page fetch succeeds + # (one candidate) and _ingest_candidates raises → the endpoint returns 422. + from fastapi.testclient import TestClient + + import app.state as state + from app.auth import require_human + from app.db import get_db + from app.main import app + from app.routes import search as search_mod + + user = User(email="route422@example.com") + db.add(user) + db.commit() + + monkeypatch.setattr(state, "_configured_cache", True) + monkeypatch.setattr( + search_mod.sysconfig, "get_str", lambda d, k: "scrape" if k == "search_source" else "" + ) + monkeypatch.setattr(search_mod.sysconfig, "get_int", lambda d, k: 100) # daily cap not reached + monkeypatch.setattr(search_mod.quota, "actions_today", lambda d, uid, action: 0) + + class _FakeYTCM: + def __init__(self, *a, **k): + pass + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + monkeypatch.setattr(search_mod, "YouTubeClient", _FakeYTCM) + monkeypatch.setattr( + search_mod.search_scrape, + "search", + lambda term, continuation=None: { + "items": [_candidate("vid_route12", "UC_route_a")], + "next_page_token": None, + }, + ) + + def _boom(db_, yt, user_, candidates): + raise RuntimeError("ingest exploded") + + monkeypatch.setattr(search_mod, "_ingest_candidates", _boom) + + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[require_human] = lambda: user + try: + resp = TestClient(app).get("/api/search/youtube?q=test+term") + finally: + app.dependency_overrides.clear() + + assert resp.status_code == 422, resp.text + assert "YouTube search failed" in resp.json()["detail"] diff --git a/frontend/src/components/AnchoredPopover.dom.test.tsx b/frontend/src/components/AnchoredPopover.dom.test.tsx new file mode 100644 index 00000000..51afe911 --- /dev/null +++ b/frontend/src/components/AnchoredPopover.dom.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom +// +// Gates the shared PopoverItem ARIA contract: aria-checked for menuitemradio/menuitemcheckbox and +// aria-selected for option are rendered as explicit attributes AFTER the floating-ui item props (so they +// win over getItemProps' single-select guess). Six live menus (LanguageSwitcher, ViewSwitcher, +// AddToPlaylist, ChannelTags, tableHeader, plexDetailUi) depend on this; without a test, a regression in +// the shared path would silently drop the checked indicator for screen-reader users. Floating-ui recipe: +// stub the two observers autoUpdate wires, wrap in a FloatingTree, query the hidden surface. +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { useState } from "react"; +import { render, screen, cleanup } from "@testing-library/react"; +import { FloatingTree } from "@floating-ui/react"; +import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); + +afterEach(cleanup); + +function Menu() { + const [open, setOpen] = useState(true); + const pop = useAnchoredPopover({ open, onOpenChange: setOpen, mode: "menu" }); + return ( + + {}}> + Newest + + {}}> + Oldest + + {}}> + Show shorts + + + ); +} + +describe("PopoverItem ARIA contract", () => { + it("exposes aria-checked on menuitemradio and menuitemcheckbox items", () => { + render( + + + , + ); + const radios = screen.getAllByRole("menuitemradio", { hidden: true }); + expect(radios.map((r) => r.getAttribute("aria-checked"))).toEqual(["true", "false"]); + const checkbox = screen.getByRole("menuitemcheckbox", { hidden: true }); + expect(checkbox.getAttribute("aria-checked")).toBe("true"); + }); +}); diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx index de4526a1..8f980402 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/AnchoredPopover.tsx @@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, + useId, useLayoutEffect, useRef, useState, @@ -61,14 +62,29 @@ import { useFullscreenElement } from "../lib/fullscreen"; // right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a // free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and // typeahead act on the text, not the list — see ChannelTags. +// +// S? adds a "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input +// with a listbox of options below), so a hand-rolled floating-ui engine (the old MultiSelectChips / +// PlexSearchBox pattern) is no longer needed. It differs from menu/listbox in exactly the focus model: +// - the trigger IS a text input, so open is driven by the caller (focus/typing), NOT useClick; +// - focus STAYS on the input — the surface is NOT focus-trapped and list items are never DOM-focused; +// arrow-nav is VIRTUAL (useListNavigation `virtual:true` → the ↑/↓/Home/End handler rides on the +// input's getReferenceProps and moves an `aria-activedescendant` pointer, not real focus). +// Because `role:"combobox"` is passed to useRole, Floating UI wires the whole ARIA tree for free: the +// reference input gets role=combobox + aria-expanded/controls/autocomplete/activedescendant, the surface +// gets role=listbox, and each gets its option id (so activedescendant has a +// target). The caller mounts the INPUT as `refs.setReference` (makes it a "typeable combobox reference") +// and the field box as `refs.setPositionReference` (so the panel anchors under the whole chips+input +// field). The universal component wraps all of this — call sites use it, not this mode raw. // Internal to the platform; call sites pass the string literal via `mode`, they don't name the type. -type PopoverMode = "menu" | "listbox" | "hover" | "dialog"; +type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox"; export interface AnchoredPopoverOptions { open: boolean; onOpenChange: (open: boolean, event?: Event) => void; - /** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */ + /** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards); + * "combobox" is a typeable input + listbox where focus stays in the field (use via ). */ mode?: PopoverMode; placement?: Placement; /** Gap between trigger and panel, px. Default 4. */ @@ -99,9 +115,14 @@ type AnchoredPopover = ReturnType; export function useAnchoredPopover(opts: AnchoredPopoverOptions) { const mode = opts.mode ?? "menu"; const isHover = mode === "hover"; + const isCombobox = mode === "combobox"; // Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds // arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves. const isNav = mode === "menu" || mode === "listbox"; + // Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND + // the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept + // out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here. + const roves = isNav || isCombobox; // null when not inside a — the hook still works, just without nested-layer awareness. const nodeId = useFloatingNodeId(); @@ -143,6 +164,19 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { }), ] : []), + // A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner + // scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input + // field box (the caller mounts it via setPositionReference), not the bare input. + ...(isCombobox + ? [ + size({ + padding: 8, + apply({ rects, elements }) { + elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`; + }, + }), + ] + : []), hide({ padding: 8 }), ], }); @@ -156,7 +190,9 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { } }, [opts.open]); - const click = useClick(context, { enabled: !isHover }); + // A combobox opens from the field's own focus/typing (wired by ), never a click-to-toggle — + // a click on the input must place the caret, not close the just-opened list. + const click = useClick(context, { enabled: !isHover && !isCombobox }); const hover = useHover(context, { enabled: isHover, move: false, @@ -170,9 +206,19 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { // stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside- // press stays with floating-ui (tree-aware). const dismiss = useDismiss(context, { escapeKey: false }); - const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") }); + // `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input + // → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav), + // the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the + // `listbox` aria-role internally.) So sets NO aria-* by hand. + const role = useRole(context, { + role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"), + }); const listNav = useListNavigation(context, { - enabled: isNav, + enabled: roves, + // Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an + // `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the + // input). A menu/listbox uses real roving focus instead. + virtual: isCombobox, listRef: elementsRef, activeIndex, // Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from @@ -188,12 +234,15 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { // `isListIndexDisabled`.) No explicit index list needed. loop: true, // Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens, - // which strands focus on the menu container — arrows then can't enter the roving list at all). - focusItemOnOpen: true, - // A menu that hosts a free-text input must NOT pull focus onto a list item when the pointer merely - // passes over it (the library default) — that blurs the input mid-typing and mis-routes the next - // Enter to the hovered item. Turned off together with typeahead for such menus. - focusItemOnHover: !opts.hostsInput, + // which strands focus on the menu container — arrows then can't enter the roving list at all). A + // combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first + // option; auto-highlighting item 0 on open would fight the typed query. + focusItemOnOpen: isCombobox ? false : true, + // A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when + // the pointer merely passes over it (the library default) — that blurs the input mid-typing and + // mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex + // instead (see PopoverItem), so the pointer still highlights without stealing the caret. + focusItemOnHover: isCombobox ? false : !opts.hostsInput, }); const typeahead = useTypeahead(context, { enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput, @@ -222,6 +271,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { nodeId, mode, isNav, + isCombobox, + roves, open: opts.open, selectedIndex: opts.selectedIndex ?? null, refs, @@ -245,6 +296,9 @@ interface PopoverListContextValue { highlightIndex: number | null; setHoverIndex: (index: number | null) => void; getItemProps: AnchoredPopover["getItemProps"]; + /** Combobox surface: items are navigated VIRTUALLY (aria-activedescendant), so a never + * takes DOM focus (roving tabindex off) and keeps the caret in the input on click. */ + virtual: boolean; } const PopoverListContext = createContext(null); @@ -302,6 +356,12 @@ export function PopoverItem({ label?: string; } & Omit, "onSelect" | "role" | "children" | "className">) { const ctx = useContext(PopoverListContext); + const virtual = ctx?.virtual ?? false; + // A virtual (combobox) option needs a STABLE DOM id from first render: the input's aria-activedescendant + // is set to the active option's `.id` when the arrow handler reads it, so the id must already be there + // (Floating UI's own on-activation id assignment races the re-render and lands empty through our + // indirection). A menu/listbox item uses roving DOM focus instead and needs no id. + const generatedId = useId(); const textLabel = label ?? (typeof children === "string" ? children : undefined); const { ref, index } = useListItem({ label: textLabel }); // Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight @@ -309,12 +369,12 @@ export function PopoverItem({ const focusActive = ctx?.activeIndex === index; const visualActive = ctx?.highlightIndex === index; - const ariaProps = - role === "menuitemradio" || role === "menuitemcheckbox" - ? { role, "aria-checked": !!checked } - : role === "option" - ? { role, "aria-selected": !!selected } - : { role }; + // The checked/selected state is rendered as an EXPLICIT attribute after {...handlers} below (so it + // wins): for a combobox `role:"combobox"` makes getItemProps emit its own `aria-selected` off the + // single selectedIndex, which is always false for a MULTI-select — our per-value truth must override. + const ariaChecked = + role === "menuitemradio" || role === "menuitemcheckbox" ? !!checked : undefined; + const ariaSelected = role === "option" ? !!selected : undefined; // Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which // composes handlers rather than dropping them — spreading `{...rest}` separately would let the @@ -332,19 +392,37 @@ export function PopoverItem({ rest.onMouseMove?.(e); if (ctx && !disabled && index != null && ctx.highlightIndex !== index) ctx.setHoverIndex(index); }; + // In a combobox, clicking an option must NOT move DOM focus off the input (that would close the list + // and drop the caret) — preventDefault the mousedown so focus stays put; the onClick still selects. + const mergedMouseDown = (e: React.MouseEvent) => { + rest.onMouseDown?.(e); + if (virtual) e.preventDefault(); + }; const handlers = ctx - ? ctx.getItemProps({ ...rest, onClick: mergedClick, onMouseMove: mergedMouseMove }) - : { ...rest, onClick: mergedClick }; + ? ctx.getItemProps({ + ...rest, + onClick: mergedClick, + onMouseMove: mergedMouseMove, + onMouseDown: mergedMouseDown, + }) + : { ...rest, onClick: mergedClick, onMouseDown: mergedMouseDown }; return ( + )} + + )} + +
+ {left && ( + + )} +
+ {children} +
+ {right && ( + + )} +
+ + ); +} diff --git a/frontend/src/components/Combobox.dom.test.tsx b/frontend/src/components/Combobox.dom.test.tsx new file mode 100644 index 00000000..7fb2a690 --- /dev/null +++ b/frontend/src/components/Combobox.dom.test.tsx @@ -0,0 +1,211 @@ +// @vitest-environment jsdom +// +// The universal combobox (chips + typed search field over the AnchoredPopover `combobox` mode). Covers +// chip removal, open-on-focus, client filter, toggling, Backspace-removes-last, the big-vocabulary cap, +// single-select, and the combobox ARIA the old hand-rolled control lacked (role=combobox, aria-expanded, +// and a virtual aria-activedescendant that keeps DOM focus on the input). i18n is key-passthrough; the +// floating-ui recipe (ActionMenu's): stub the two observers autoUpdate wires, wrap in a FloatingTree, +// and query the hidden surface with `{ hidden: true }` since jsdom never positions it. +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { FloatingTree } from "@floating-ui/react"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; +}); + +import Combobox from "./Combobox"; + +afterEach(cleanup); + +const OPTIONS = [ + { value: "Drama", count: 120 }, + { value: "Action", count: 90 }, + { value: "Comedy", count: 60 }, +]; + +function wrap(ui: React.ReactElement) { + return render({ui}); +} + +describe("Combobox", () => { + it("renders selected values as removable chips and removes on ✕", () => { + const onChange = vi.fn(); + wrap(); + expect(screen.getByText("Drama")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "common.remove" })); + expect(onChange).toHaveBeenCalledWith([]); // Drama removed + }); + + it("exposes combobox ARIA: role=combobox, aria-expanded toggling, aria-controls when open", () => { + wrap(); + const input = screen.getByRole("combobox"); + expect(input.getAttribute("aria-expanded")).toBe("false"); + expect(input.getAttribute("aria-controls")).toBeNull(); + fireEvent.focus(input); + expect(input.getAttribute("aria-expanded")).toBe("true"); + // aria-controls now points at the listbox surface. + const controls = input.getAttribute("aria-controls"); + expect(controls).toBeTruthy(); + expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox"); + }); + + it("keeps the option list unmounted until the field is focused", () => { + wrap(); + expect(screen.queryByRole("option", { hidden: true })).toBeNull(); + fireEvent.focus(screen.getByRole("combobox")); + expect(screen.getAllByRole("option", { hidden: true }).length).toBe(3); + }); + + it("filters the options by the typed query", () => { + wrap(); + const field = screen.getByRole("combobox"); + fireEvent.focus(field); + fireEvent.change(field, { target: { value: "com" } }); + const items = screen.getAllByRole("option", { hidden: true }); + expect(items.length).toBe(1); + expect(items[0]?.textContent).toContain("Comedy"); + }); + + it("marks a selected option aria-selected and toggles another on", () => { + const onChange = vi.fn(); + wrap(); + fireEvent.focus(screen.getByRole("combobox")); + const drama = screen + .getAllByRole("option", { hidden: true }) + .find((el) => el.textContent?.includes("Drama")); + expect(drama?.getAttribute("aria-selected")).toBe("true"); + fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on + expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]); + }); + + it("moves an aria-activedescendant on ArrowDown while DOM focus stays on the input", () => { + wrap(); + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "ArrowDown" }); + // Virtual navigation: the input keeps DOM focus, the active option is referenced by id. + expect(document.activeElement).toBe(input); + expect(input.getAttribute("aria-activedescendant")).toBeTruthy(); + }); + + it("removes the last chip on Backspace in an empty field", () => { + const onChange = vi.fn(); + wrap( + , + ); + fireEvent.keyDown(screen.getByRole("combobox"), { key: "Backspace" }); + expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped + }); + + it("caps the rendered rows for a large vocabulary and shows a refine hint", () => { + // A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer. + const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i })); + wrap(); + fireEvent.focus(screen.getByRole("combobox")); + expect(screen.getAllByRole("option", { hidden: true }).length).toBe(50); + expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search + }); + + it("Enter commits the pointer-highlighted option, not the arrow-active one", () => { + // The visible highlight follows the pointer (hoverIndex); Enter must commit THAT row, not the bare + // keyboard activeIndex — otherwise arrow-then-hover-then-Enter toggles a different option than shown. + const onChange = vi.fn(); + wrap(); + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.focus(input); + fireEvent.keyDown(input, { key: "ArrowDown" }); // activeIndex → 0 (Drama) + const action = screen + .getAllByRole("option", { hidden: true }) + .find((el) => el.textContent?.includes("Action"))!; + fireEvent.mouseMove(action); // hoverIndex → Action (the visibly highlighted row) + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith(["Action"]); // the highlighted (hovered) row, not Drama + }); + + it("Enter commits a hover-highlighted option even with no prior arrow key", () => { + const onChange = vi.fn(); + wrap(); + const input = screen.getByRole("combobox"); + input.focus(); + fireEvent.focus(input); + const comedy = screen + .getAllByRole("option", { hidden: true }) + .find((el) => el.textContent?.includes("Comedy"))!; + fireEvent.mouseMove(comedy); // highlight via hover, activeIndex still null + fireEvent.keyDown(input, { key: "Enter" }); + expect(onChange).toHaveBeenCalledWith(["Comedy"]); // no longer a silent no-op + }); + + it("async mode: controlled query + onQueryChange/onOpenChange, no client filter, selectedLabels chip", () => { + const onQueryChange = vi.fn(); + const onOpenChange = vi.fn(); + const onChange = vi.fn(); + const serverOpts = [{ value: "id1", label: "Harry Potter", count: 8 }]; + wrap( + , + ); + // The selected value's option isn't in the (server-narrowed) list, so its chip uses selectedLabels. + expect(screen.getByText("Stored Collection")).toBeTruthy(); + const input = screen.getByRole("combobox"); + fireEvent.focus(input); + expect(onOpenChange).toHaveBeenCalledWith(true); + fireEvent.change(input, { target: { value: "harr" } }); + expect(onQueryChange).toHaveBeenCalledWith("harr"); // controlled query propagates out + // clientFilter=false: the single server option renders verbatim (not re-filtered against "har"). + const opts = screen.getAllByRole("option", { hidden: true }); + expect(opts.length).toBe(1); + expect(opts[0]?.textContent).toContain("Harry Potter"); + fireEvent.click(screen.getByText("Harry Potter")); + expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces + }); + + it("single-select: picking replaces the value and closes the list", () => { + const onChange = vi.fn(); + wrap( + , + ); + fireEvent.focus(screen.getByRole("combobox")); + fireEvent.click(screen.getByText("Action")); + expect(onChange).toHaveBeenCalledWith(["Action"]); // replaced, not appended + expect(screen.getByRole("combobox").getAttribute("aria-expanded")).toBe("false"); // closed + }); +}); diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx new file mode 100644 index 00000000..8cfab27b --- /dev/null +++ b/frontend/src/components/Combobox.tsx @@ -0,0 +1,272 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronDown, X } from "lucide-react"; +import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover"; +import { useScrollFade } from "../lib/useScrollFade"; + +// The universal combobox: a react-select / Overseerr-style "combi input" — selected values are +// removable chips that live INSIDE a text field you type into, and a checklist of options opens below. +// It is the ONE control for "pick value(s) from a (possibly large, possibly async) vocabulary": the E5 +// Plex filters (genre / age-rating / studio, multi-select) and the collection picker (single-select, +// server-searched) are both instances, and the header search typeahead can move onto it later. +// +// All the fiddly parts live in the AnchoredPopover `combobox` mode, NOT here: floating-ui positioning, +// the paint-before-position + transform-clobber flash guards, the topmost-only Escape layer, and the +// FULL combobox ARIA tree (input role=combobox + aria-expanded/controls/activedescendant, listbox +// surface, option ids, virtual ↑/↓ arrow-nav that keeps the caret in the field). This component only +// adds the field chrome (chips + input + chevron), the query/selection state, and the big-vocabulary +// render cap. That is what makes it accessible where the old hand-rolled MultiSelectChips was not. + +export interface ComboboxOption { + value: string; + /** Display text; defaults to `value`. */ + label?: string; + /** A facet count shown at the row's end. */ + count?: number; + disabled?: boolean; +} + +export interface ComboboxProps { + /** Accessible name for the input (the section heading usually names it visibly too). */ + label: string; + options: readonly ComboboxOption[]; + /** Selected values. Single-select carries 0 or 1. */ + selected: string[]; + onChange: (next: string[]) => void; + /** Several values at once (chips accumulate) vs one (picking replaces + closes). Default true. */ + multiple?: boolean; + /** Notified when the list opens/closes — a server-searched caller gates its fetch on `open`. */ + onOpenChange?: (open: boolean) => void; + placeholder?: string; + /** Controlled query text — pass with `onQueryChange` for a server-searched (async) vocabulary; omit + * to let the combobox own the query state and filter `options` on the client. */ + query?: string; + onQueryChange?: (q: string) => void; + /** Filter `options` by the query on the client (default). Set false when the caller already narrows + * the list server-side (then `options` arrives pre-filtered). */ + clientFilter?: boolean; + loading?: boolean; + /** Shown when there are no options to list. */ + emptyText?: string; + /** Cap on rendered rows for a huge vocabulary; the rest hide behind the search. Default 50. */ + maxVisible?: number; + /** Custom row body (icon + label + count …); defaults to a checkbox + label + count. */ + renderOption?: (option: ComboboxOption, isSelected: boolean) => ReactNode; + /** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed) + * list — the collection picker passes the stored title here so a reloaded filter still names itself. */ + selectedLabels?: Record; +} + +export default function Combobox({ + label, + options, + selected, + onChange, + multiple = true, + onOpenChange, + placeholder, + query: queryProp, + onQueryChange, + clientFilter = true, + loading = false, + emptyText, + maxVisible = 50, + renderOption, + selectedLabels, +}: ComboboxProps) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + // The query is controlled when the caller drives it (async search), else owned here. + const [queryState, setQueryState] = useState(""); + const query = queryProp ?? queryState; + const setQuery = (q: string) => { + if (queryProp === undefined) setQueryState(q); + onQueryChange?.(q); + }; + const inputRef = useRef(null); + const fade = useScrollFade(); + + // Remember each value's display label as options flow through, so a selected chip can name itself + // even after the option leaves the query-narrowed list (async single-select). Caller-supplied + // `selectedLabels` win (they survive a page reload where nothing has been fetched yet). + const labelCache = useRef>({}); + useEffect(() => { + for (const o of options) if (o.label != null) labelCache.current[o.value] = o.label; + }, [options]); + const labelFor = (v: string) => selectedLabels?.[v] ?? labelCache.current[v] ?? v; + + const setOpenAnd = (next: boolean) => { + setOpen(next); + onOpenChange?.(next); + }; + const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" }); + // `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter + // commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle + // a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null) + // would make Enter a no-op over a highlighted row. + const { activeIndex, highlightIndex } = pop; + + // Clear the typed query when the list closes. + useEffect(() => { + if (!open) setQuery(""); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const filtered = useMemo(() => { + if (!clientFilter) return options; + const q = query.trim().toLowerCase(); + if (!q) return options; + return options.filter((o) => (o.label ?? o.value).toLowerCase().includes(q)); + }, [options, query, clientFilter]); + + // Options arrive count-desc (facet order), so the capped default view is the most-common values; a + // footer flags how many more are reachable through the search. + const visible = filtered.slice(0, maxVisible); + const hiddenCount = filtered.length - visible.length; + + const commit = (value: string) => { + if (multiple) { + onChange( + selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value], + ); + } else { + onChange([value]); + setOpenAnd(false); + } + inputRef.current?.focus(); + }; + + return ( +
+ {/* The field: removable chips + a free-text input + a chevron, styled as one input box. The whole + box is the POSITION anchor (the panel matches its width); the input is the combobox reference. */} +
{ + // A press anywhere but the input (padding / a chip gap) focuses the field and opens the list — + // focus drives "open", so a caret click never toggles it shut. + if (e.target !== inputRef.current) { + e.preventDefault(); + inputRef.current?.focus(); + setOpenAnd(true); + } + }} + className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30" + > + {selected.map((v) => ( + + {labelFor(v)} + + + ))} + { + setQuery((e.target as HTMLInputElement).value); + setOpenAnd(true); + }, + onFocus: () => setOpenAnd(true), + onKeyDown: (e) => { + const idx = highlightIndex ?? activeIndex; + if (e.key === "Enter" && open && idx != null && visible[idx]) { + e.preventDefault(); // select the highlighted option, don't submit a surrounding form + if (!visible[idx].disabled) commit(visible[idx].value); + } else if (e.key === "Backspace" && query === "" && selected.length > 0) { + // Backspace on an empty field removes the last chip (react-select behaviour). + onChange(selected.slice(0, -1)); + } + }, + })} + value={query} + aria-label={label} + placeholder={ + selected.length === 0 + ? (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" + /> + +
+ + + {/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */} +
+ {loading && filtered.length === 0 ? ( +

+ {t("plex.loading", { defaultValue: "Loading…" })} +

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

+ {emptyText ?? t("common.noResults", { defaultValue: "No matches" })} +

+ ) : ( + <> + {visible.map((o) => { + const on = selected.includes(o.value); + return ( + commit(o.value)} + label={o.label ?? o.value} + className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${ + on ? "text-fg" : "text-muted" + }`} + > + {renderOption ? ( + renderOption(o, on) + ) : ( + <> + + {on && } + + {o.label ?? o.value} + {o.count != null && ( + + {o.count} + + )} + + )} + + ); + })} + {hiddenCount > 0 && ( +

+ {t("plex.filter.moreRefine", { + count: hiddenCount, + defaultValue: "{{count}} more — refine with search", + })} +

+ )} + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 545d4770..de290cb5 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -25,6 +25,8 @@ 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 PlexDiscover from "./PlexDiscover"; import { PageToolbar } from "./PageShell"; import ActiveFilterChips from "./ActiveFilterChips"; import { StateMessage } from "./QueryState"; @@ -70,7 +72,9 @@ export default function PlexBrowse() { const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); // Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field. const fieldState = healPlexField(field, scope); - const sub = useHistorySubview({ kind: "grid" }, PLEX_SUB_URL); + // Discover is the landing (E5 S4); the flat grid is the "Browse all" secondary view. A header search + // or an added filter is a browse intent → flip to the grid (below); an explicit toggle returns. + const sub = useHistorySubview({ kind: "discover" }, PLEX_SUB_URL); // 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. @@ -95,6 +99,19 @@ 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; + const prevIntent = useRef(browseIntent); + useEffect(() => { + if (sub.view.kind === "discover" && browseIntent && !prevIntent.current) { + sub.open({ kind: "grid" }); + } + prevIntent.current = browseIntent; + }, [browseIntent, sub]); + // Opening a show/player replaces the grid entirely (the component returns early below), so the // scroll container resets to the top. Remember where the grid was scrolled when leaving it, and // restore it when we come back — so the browser/mouse Back from the player lands on the same card @@ -124,7 +141,7 @@ export default function PlexBrowse() { const tag = (el?.tagName || "").toLowerCase(); if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable) return; - if (["show", "season", "info", "person", "playlist"].includes(sub.view.kind)) { + if (["grid", "show", "season", "info", "person", "playlist"].includes(sub.view.kind)) { e.preventDefault(); sub.back(); } @@ -337,12 +354,42 @@ export default function PlexBrowse() { ); } + // Discover ⟷ Browse switch, shown on both surfaces so either can return to the other. + const viewToggle = ( + sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })} + /> + ); + + if (sub.view.kind === "discover") { + return ( +
+
{viewToggle}
+ applyFilter({ genres: [g] })} + onSeeAllRecent={() => sub.open({ kind: "grid" })} + /> +
+ ); + } + return (
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put while only the poster grid scrolls. */}
+ {viewToggle}

{browseQ.isLoading ? " " diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx new file mode 100644 index 00000000..7a204232 --- /dev/null +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -0,0 +1,181 @@ +// @vitest-environment jsdom +// +// E5 S4: the Plex Discover landing. Covers the rail surface served from the mocked /discover + +// /surprise calls — that each non-empty rail renders, the genre tile fires onGenre, the recently-added +// "see all" fires onSeeAllRecent, the ↻ reshuffle refetches the surprise rail alone, and an all-empty +// payload shows the empty message. i18n is key-passthrough; the api is mocked so queries resolve +// synchronously. The rails scroll inside a Carousel (useHScroll → ResizeObserver), stubbed for jsdom. +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; +import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + // Interpolate {{title}} so the because-you-watched heading is asserted with its anchor name. + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.title ? `${key}:${opts.title}` : key, + }), + }; +}); + +const plexDiscover = vi.fn(); +const plexSurprise = vi.fn(); +const plexSetState = vi.fn((..._a: unknown[]) => Promise.resolve()); +vi.mock("../lib/api", () => ({ + api: { + plexDiscover: (...a: unknown[]) => plexDiscover(...a), + plexSurprise: (...a: unknown[]) => plexSurprise(...a), + plexSetState: (...a: unknown[]) => plexSetState(...a), + }, +})); + +import PlexDiscover from "./PlexDiscover"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({ + id, + type, + title, + year: 2000, + duration_seconds: 3600, + thumb: `/api/plex/image/${id}`, + playable: "direct", + status: "new", + position_seconds: 0, +}); + +const fullPayload = { + scope: "both", + continue_watching: [card("cw1", "Resume Me")], + recently_added: [card("ra1", "Just Added")], + genres: [{ value: "Drama", count: 12 }], + because_you_watched: { anchor: { id: "a1", title: "Anchor" }, items: [card("bw1", "Rec One")] }, +}; + +function wrap(ui: React.ReactElement) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +const noop = () => {}; + +describe("PlexDiscover", () => { + it("renders each non-empty rail, the genre tiles, and the surprise sample", async () => { + plexDiscover.mockResolvedValue(fullPayload); + plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); + wrap( + , + ); + await screen.findByText("plex.discover.continueWatching"); + expect(screen.getByText("plex.discover.recentlyAdded")).toBeTruthy(); + expect(screen.getByText("plex.discover.surprise")).toBeTruthy(); + expect(screen.getByText("plex.discover.byGenre")).toBeTruthy(); + // The because-you-watched heading interpolates the anchor title. + 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("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"); + }); + + it("fires onGenre with the tile's value", async () => { + plexDiscover.mockResolvedValue(fullPayload); + plexSurprise.mockResolvedValue({ scope: "both", items: [] }); + const onGenre = vi.fn(); + wrap( + , + ); + fireEvent.click(await screen.findByText("Drama")); + expect(onGenre).toHaveBeenCalledWith("Drama"); + }); + + it("fires onSeeAllRecent from the recently-added rail's see-all", async () => { + plexDiscover.mockResolvedValue(fullPayload); + plexSurprise.mockResolvedValue({ scope: "both", items: [] }); + const onSeeAllRecent = vi.fn(); + wrap( + , + ); + fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" })); + expect(onSeeAllRecent).toHaveBeenCalledOnce(); + }); + + it("reshuffles the surprise rail alone (refetch) without refetching the surface", async () => { + plexDiscover.mockResolvedValue(fullPayload); + plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); + wrap( + , + ); + await screen.findByText("Random Pick"); + expect(plexSurprise).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "plex.discover.reshuffle" })); + await waitFor(() => expect(plexSurprise).toHaveBeenCalledTimes(2)); + expect(plexDiscover).toHaveBeenCalledTimes(1); // the whole surface was NOT refetched + }); + + it("shows the empty message when every rail is empty", async () => { + plexDiscover.mockResolvedValue({ + scope: "both", + continue_watching: [], + recently_added: [], + genres: [], + because_you_watched: null, + }); + plexSurprise.mockResolvedValue({ scope: "both", items: [] }); + wrap( + , + ); + await screen.findByText("plex.discover.empty"); + }); +}); diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx new file mode 100644 index 00000000..ad19b79b --- /dev/null +++ b/frontend/src/components/PlexDiscover.tsx @@ -0,0 +1,235 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +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 { plexLang } from "../i18n"; +import { toggleWatchState } from "../lib/plexWatch"; +import { Carousel } from "./Carousel"; +import { PlexPosterCard } from "./plexCards"; +import { LoadingState, StateMessage } from "./QueryState"; + +// The Plex "Discover" landing (E5 S4): a stack of horizontal Carousel rails served from ONE +// /api/plex/discover call — Continue watching, Recently added, Browse by genre (tiles), Because you +// watched X — plus a separately-fetched, reshuffling Surprise-me rail. The flat browse grid stays its +// own view (PlexBrowse's grid branch); a header search or filter flips the user there. Card click / +// 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, + seeAllLabel, + onSeeAll, + cards, + onOpenCard, + onInfoCard, + onToggleWatched, + action, +}: { + title: ReactNode; + ariaLabel: string; + seeAllLabel?: string; + onSeeAll?: () => void; + cards: PlexCard[]; + onOpenCard: (c: PlexCard) => void; + onInfoCard: (c: PlexCard) => void; + onToggleWatched: (c: PlexCard) => void; + action?: ReactNode; // optional header control (e.g. the Surprise-me reshuffle button) +}) { + const heading = + action != null ? ( + + {title} + {action} + + ) : ( + title + ); + return ( +

+ + {cards.map((c) => ( +
+ onOpenCard(c)} + onInfo={() => onInfoCard(c)} + onToggleWatched={onToggleWatched} + /> +
+ ))} +
+
+ ); +} + +export default function PlexDiscover({ + scope, + onOpenCard, + onInfoCard, + onGenre, + onSeeAllRecent, +}: { + scope: string; + onOpenCard: (c: PlexCard) => void; + onInfoCard: (c: PlexCard) => void; + onGenre: (genre: string) => void; + onSeeAllRecent: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const lang = plexLang(); + const q = useQuery({ + queryKey: qk.plexDiscover(scope, lang), + queryFn: () => api.plexDiscover(scope, lang), + }); + // 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), + staleTime: 0, + }); + + // Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise + // list, then persist + resync via the shared helper — invalidating the grid too so it agrees. + const toggleWatched = (card: PlexCard) => + toggleWatchState(card, t, { + optimistic: (next) => { + const flip = (cards: PlexCard[]) => + cards.map((c) => (c.id === card.id ? { ...c, status: next } : c)); + qc.setQueryData(qk.plexDiscover(scope, lang), (old) => + old + ? { + ...old, + continue_watching: flip(old.continue_watching), + recently_added: flip(old.recently_added), + because_you_watched: old.because_you_watched + ? { ...old.because_you_watched, items: flip(old.because_you_watched.items) } + : null, + } + : old, + ); + qc.setQueryData<{ scope: string; items: PlexCard[] }>(qk.plexSurprise(scope), (old) => + old ? { ...old, items: flip(old.items) } : old, + ); + }, + resync: () => { + qc.invalidateQueries({ queryKey: qk.plexDiscover() }); + qc.invalidateQueries({ queryKey: qk.plexLibrary() }); + }, + }); + + if (q.isError && !q.data) { + return ( + q.refetch()}> + {t("common.loadError")} + + ); + } + if (q.isLoading || !q.data) { + return ; + } + + const d = q.data; + const surprise = surpriseQ.data?.items ?? []; + const bw = d.because_you_watched; + const nothing = + d.continue_watching.length === 0 && + d.recently_added.length === 0 && + d.genres.length === 0 && + surprise.length === 0 && + (!bw || bw.items.length === 0); + + if (nothing) { + return

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

; + } + + const railProps = { onOpenCard, onInfoCard, onToggleWatched: toggleWatched }; + + return ( +
+ {d.continue_watching.length > 0 && ( + + )} + + {d.recently_added.length > 0 && ( + + )} + + {surprise.length > 0 && ( + surpriseQ.refetch()} + aria-label={t("plex.discover.reshuffle")} + title={t("plex.discover.reshuffle")} + className="rounded-full p-1 text-muted transition hover:text-accent" + > + + + } + {...railProps} + /> + )} + + {d.genres.length > 0 && ( +
+

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

+
+ {d.genres.map((g) => { + const hue = genreHue(g.value); + return ( + + ); + })} +
+
+ )} + + {bw && bw.items.length > 0 && ( + + )} +
+ ); +} diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx new file mode 100644 index 00000000..13d6b4a6 --- /dev/null +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +// +// E5 S5: the enriched Plex detail page. Covers the TMDB-fed pieces added on the page variant — the +// multi-source ratings row (IMDb / TMDb / audience), the facts panel (budget / revenue / studios / +// countries), the keyword chips (→ onFilter), and the full crew (→ onOpenPerson). i18n is +// key-passthrough; PlexInfo is prop-driven (no queries) so it just needs a QueryClient for its cache +// reads. The overlay variant is asserted to stay lean (no facts/keywords/crew). +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts?.name ? `${key}:${opts.name}` : key, + }), + }; +}); + +import PlexInfo from "./PlexInfo"; +import type { PlexItemDetail } from "../lib/api"; + +afterEach(cleanup); + +const detail: PlexItemDetail = { + id: "m1", + kind: "movie", + title: "Test Movie", + summary: "A summary.", + year: 2010, + duration_seconds: 7200, + playable: "direct", + thumb: "/api/plex/image/m1", + art: "/api/plex/image/m1?variant=art", + cast: [], + imdb_rating: 7.8, + imdb_id: "tt1", + imdb_url: "https://www.imdb.com/title/tt1", + rating: 8.4, // Plex audience + content_rating: "R", + genres: ["Crime"], + directors: ["Plex Director"], + studio: "Fallback Studio", + tagline: "A tagline.", + markers: [], + audio_streams: [], + subtitle_streams: [], + status: "new", + position_seconds: 0, + tmdb: { + tmdb_id: 42, + rating: 7.2, + vote_count: 1234, + budget: 160000000, + revenue: 830000000, + keywords: ["heist", "bank robbery"], + crew: [ + { name: "Jane Director", job: "Director" }, + { name: "Sam Writer", job: "Writer" }, + ], + studios: ["Studio A", "Studio B"], + production_countries: ["United States"], + recommendations: [], + backdrops: [], + }, +}; + +function wrap(ui: React.ReactElement) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +} + +describe("PlexInfo enriched detail (page)", () => { + it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => { + wrap(); + expect(screen.getByText("IMDb")).toBeTruthy(); + expect(screen.getByText("7.8")).toBeTruthy(); + expect(screen.getByText(/TMDb/)).toBeTruthy(); // "TMDb · 1.2K" (vote count compacted) + expect(screen.getByText("7.2")).toBeTruthy(); + expect(screen.getByText("plex.info.audience")).toBeTruthy(); + expect(screen.getByText("8.4")).toBeTruthy(); + }); + + it("renders the facts panel with budget / revenue / studios / countries", () => { + wrap(); + expect(screen.getByText("plex.info.facts.title")).toBeTruthy(); + expect(screen.getByText("plex.info.facts.budget")).toBeTruthy(); + expect(screen.getByText("$160M")).toBeTruthy(); + expect(screen.getByText("$830M")).toBeTruthy(); + expect(screen.getByText("Studio A")).toBeTruthy(); // tmdb.studios preferred over the fallback + expect(screen.getByText("United States")).toBeTruthy(); + }); + + it("keyword chips filter the library on click", () => { + const onFilter = vi.fn(); + wrap(); + fireEvent.click(screen.getByText("heist")); + expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] }); + }); + + it("crew members open the person page on click", () => { + const onOpenPerson = vi.fn(); + wrap(); + expect(screen.getByText("plex.info.crew")).toBeTruthy(); + fireEvent.click(screen.getByText("Jane Director")); + expect(onOpenPerson).toHaveBeenCalledWith("Jane Director"); + }); + + it("RatingsRow: with no IMDb score, the audience score becomes the filterable one", () => { + const onFilter = vi.fn(); + const noImdb = { ...detail, imdb_rating: null, imdb_id: null, imdb_url: null }; + wrap(); + expect(screen.queryByText("IMDb")).toBeNull(); + // audience (8.4) is now the primary/filterable score → clicking it filters ratingMin. + fireEvent.click(screen.getByText("8.4")); + expect(onFilter).toHaveBeenCalledWith({ ratingMin: 8 }); + }); + + it("RatingsRow: suppresses the duplicate audience chip when it equals the IMDb score", () => { + const dupe = { ...detail, rating: 7.8 }; // audience == imdb_rating (7.8) + wrap(); + expect(screen.getByText("IMDb")).toBeTruthy(); + expect(screen.queryByText("plex.info.audience")).toBeNull(); // duplicate audience chip suppressed + expect(screen.getAllByText("7.8")).toHaveLength(1); // the score shows once (IMDb only) + }); + + it("the overlay variant stays lean (no facts / keywords / crew panels)", () => { + wrap(); + expect(screen.queryByText("plex.info.facts.title")).toBeNull(); + expect(screen.queryByText("plex.info.keywords")).toBeNull(); + expect(screen.queryByText("plex.info.crew")).toBeNull(); + // but the ratings row still renders (it's compact enough for the overlay) + expect(screen.getByText("IMDb")).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 10de6c2a..362d5d48 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -1,19 +1,22 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { api, type PlexFilters, type PlexItemDetail } from "../lib/api"; import { formatRuntime } from "../lib/format"; -import { pickRatingBadge } from "../lib/plexRating"; import { notifyActionError } from "../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle, - useArtBackdrop, + tmdbBackdropUrl, + useCrossfadeBackdrop, useDetailPrefs, } from "../lib/plexDetailUi"; +import { usePref } from "../lib/usePref"; +import i18n from "../i18n"; +import { Carousel } from "./Carousel"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; @@ -22,6 +25,233 @@ import PlexPlaylistAdd from "./PlexPlaylistAdd"; // a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay // over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable // personal preferences, persisted in users.preferences: the faint art backdrop and the cast row. +// The TMDB-enriched pieces (multi-source ratings row, facts, keyword chips, full crew — E5 S5) render +// on the page variant only; the overlay stays lean. + +// Localize the grouping/compact notation to the active language (the app convention, lib/api/core.ts); +// `i18n.language` is read at call time, and the component re-renders on a language switch. Currency stays +// USD — TMDB budget/revenue figures are USD regardless of the viewer's language. +const compact = (n: number) => + new Intl.NumberFormat(i18n.language, { notation: "compact" }).format(n); +const money = (n: number) => + new Intl.NumberFormat(i18n.language, { + notation: "compact", + style: "currency", + currency: "USD", + maximumFractionDigits: 1, + }).format(n); + +// Multi-source ratings cluster (E5 S5): IMDb (links out) · TMDb (with vote count) · Plex audience — +// whichever are present. The "primary" score (IMDb, else audience) mirrors the backend rating column +// (coalesce(imdb, audience)), so only it carries the ratingMin filter; the others are informational. +function RatingsRow({ + imdbRating, + imdbUrl, + audience, + tmdbRating, + tmdbVotes, + onFilterRating, +}: { + imdbRating?: number | null; + imdbUrl?: string | null; + audience?: number | null; + tmdbRating?: number | null; + tmdbVotes?: number | null; + onFilterRating?: (score: number) => void; +}) { + const { t } = useTranslation(); + const primary = imdbRating ?? audience ?? null; // the filterable score + const chipCls = + "inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500"; + const Score = ({ score, filterable }: { score: number; filterable: boolean }) => + filterable && onFilterRating ? ( + + ) : ( + + + {score.toFixed(1)} + + ); + const has = (x?: number | null): x is number => x != null; + if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null; + return ( +
+ {has(imdbRating) && ( + + + IMDb + {imdbUrl && ( + + + + )} + + )} + {has(tmdbRating) && ( + + + + TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""} + + + )} + {has(audience) && imdbRating !== audience && ( + + + {t("plex.info.audience")} + + )} +
+ ); +} + +// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries, +// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something. +function FactsPanel({ + detail, + onFilter, +}: { + detail: PlexItemDetail; + onFilter?: (patch: Partial) => void; +}) { + const { t } = useTranslation(); + const tmdb = detail.tmdb; + const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : []; + const rows: { key: string; label: string; node: ReactNode }[] = []; + if (detail.year != null) + rows.push({ key: "year", label: t("plex.info.facts.release"), node: detail.year }); + if (detail.duration_seconds) + rows.push({ + key: "runtime", + label: t("plex.info.facts.runtime"), + node: formatRuntime(detail.duration_seconds), + }); + if (detail.content_rating) + rows.push({ key: "cert", label: t("plex.info.facts.cert"), node: detail.content_rating }); + if (tmdb?.budget) + rows.push({ key: "budget", label: t("plex.info.facts.budget"), node: money(tmdb.budget) }); + if (tmdb?.revenue) + rows.push({ key: "revenue", label: t("plex.info.facts.revenue"), node: money(tmdb.revenue) }); + if (studios.length) + rows.push({ + key: "studios", + label: t("plex.info.facts.studios"), + node: ( + + {studios.map((s, i) => ( + + onFilter({ studios: [s] }) : undefined} + className="hover:text-accent" + > + {s} + + {i < studios.length - 1 ? "," : ""} + + ))} + + ), + }); + if (tmdb?.production_countries?.length) + rows.push({ + key: "countries", + label: t("plex.info.facts.countries"), + node: tmdb.production_countries.join(", "), + }); + if (rows.length === 0) return null; + return ( +
+

{t("plex.info.facts.title")}

+
+ {rows.map((r) => ( +
+
{r.label}
+
{r.node}
+
+ ))} +
+
+ ); +} + +// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter). +function KeywordsPanel({ + keywords, + onFilter, +}: { + keywords: string[]; + onFilter?: (patch: Partial) => void; +}) { + const { t } = useTranslation(); + if (keywords.length === 0) return null; + return ( +
+

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

+
+ {keywords.map((k) => ( + onFilter({ keywords: [k] }) : undefined} + > + {k} + + ))} +
+
+ ); +} + +// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open +// their person page. Complements the hero's Director line (which comes from Plex directly). +function CrewPanel({ + crew, + onOpenPerson, +}: { + crew: { name: string; job: string }[]; + onOpenPerson?: (name: string) => void; +}) { + const { t } = useTranslation(); + if (crew.length === 0) return null; + return ( +
+

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

+ {/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width, + so a fixed small column count keeps names from over-spreading. */} +
+ {crew.map((c, i) => ( +
+ {onOpenPerson ? ( + + ) : ( +
{c.name}
+ )} +
{c.job}
+
+ ))} +
+
+ ); +} type Props = { detail: PlexItemDetail; @@ -57,7 +287,12 @@ export default function PlexInfo({ // Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs: // artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding // is PlexInfo-specific state, persisted through the same savePref. - const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs(); + const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } = + useDetailPrefs(); + // The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row. + const [showFacts, setShowFacts] = usePref("plexInfoFacts", true); + const [showKeywords, setShowKeywords] = usePref("plexInfoKeywords", true); + const [showCrew, setShowCrew] = usePref("plexInfoCrew", true); const prefs = (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? {}) as { plexHiddenStripSources?: string[] }; // Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled @@ -76,9 +311,15 @@ export default function PlexInfo({ const [editorOpen, setEditorOpen] = useState(false); const [playlistOpen, setPlaylistOpen] = useState(false); - // Faint art backdrop, HTPC-style (page variant only) — the shared hook paints the item's art as a - // fixed background on
and clears it on unmount / when disabled / in the overlay variant. - useArtBackdrop(detail.art, variant !== "overlay" && artBg); + // Faint art backdrop, HTPC-style (page variant only) — cross-dissolves through the item's own art, + // its TMDB backdrops (E5 S2), and its collection siblings' art (E5 S8). Cleared in the overlay + // variant / when disabled / on unmount. + const backdropPool = [ + detail.art, + ...(detail.tmdb?.backdrops ?? []).map(tmdbBackdropUrl), + ...(detail.collections?.flatMap((c) => c.items.map((i) => i.art)) ?? []), + ]; + useCrossfadeBackdrop(backdropPool, variant !== "overlay" && artBg, crossfade); const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched"; const setState = async (status: "new" | "watched") => { @@ -105,9 +346,24 @@ export default function PlexInfo({ const sourceLabel = (src: string) => t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() }); + // Whether each enriched side panel has anything to show — drives both its render and its toggle row + // (no toggle for a panel that would be empty anyway). + const tmdb = detail.tmdb; + const hasFacts = + detail.year != null || + !!detail.duration_seconds || + !!detail.content_rating || + !!tmdb?.budget || + !!tmdb?.revenue || + !!tmdb?.studios?.length || + !!detail.studio || + !!tmdb?.production_countries?.length; + const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0; + const hasCrew = (tmdb?.crew?.length ?? 0) > 0; + return (
-
+
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
{/* Header: close (overlay) / customize */} @@ -126,17 +382,44 @@ export default function PlexInfo({ overlay={overlay} artBg={artBg} onToggleArtBg={toggleArtBg} + crossfade={crossfade} + onToggleCrossfade={toggleCrossfade} showCast={showCast} onToggleCast={toggleCast} hasCast={detail.cast.length > 0} - extra={presentSources.map((src) => ( - toggleSource(src)} - /> - ))} + extra={ + <> + {hasFacts && ( + setShowFacts(!showFacts)} + /> + )} + {hasKeywords && ( + setShowKeywords(!showKeywords)} + /> + )} + {hasCrew && ( + setShowCrew(!showCrew)} + /> + )} + {presentSources.map((src) => ( + toggleSource(src)} + /> + ))} + + } /> {overlay && onClose && ( - {isImdb && detail.imdb_url && ( - - - - )} - - ); - })()} +
+ + {/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */} +
+ onFilter({ ratingMin: n }) : undefined} + />
{detail.tagline && ( @@ -342,13 +601,26 @@ export default function PlexInfo({
{/* End hero panel. */} + {/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords / + Crew sit side by side (each its natural height, items-start) so no single box hogs the full + width with dead space; each is independently hideable from the customize menu. */} + {!overlay && (showFacts || showKeywords || showCrew) && ( +
+ {showFacts && } + {showKeywords && ( + + )} + {showCrew && } +
+ )} + {/* Cast row (toggleable), circular photos. */} {cast.length > 0 && (

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

-
+ {cast.map((c, i) => { const clickable = !!(onOpenPerson || onFilter); const inner = ( @@ -403,10 +675,12 @@ export default function PlexInfo({
); })} -
+
)} + {/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */} + {/* Collection strips — the other titles in this movie's collection(s), directly playable. Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */} {detail.collections @@ -426,7 +700,7 @@ export default function PlexInfo({ )}
-
+ {col.items.map((m) => (
: null} ))} -
+
))} diff --git a/frontend/src/components/PlexPersonView.dom.test.tsx b/frontend/src/components/PlexPersonView.dom.test.tsx index f91c2b0e..2954b48b 100644 --- a/frontend/src/components/PlexPersonView.dom.test.tsx +++ b/frontend/src/components/PlexPersonView.dom.test.tsx @@ -5,10 +5,23 @@ // filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero, // the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough // and the api is mocked so the query resolves synchronously in the test. -import { describe, it, expect, afterEach, vi } from "vitest"; +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +beforeAll(() => { + // CastStrip now scrolls inside a Carousel, whose useHScroll wires a ResizeObserver; jsdom ships none. + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; @@ -72,6 +85,12 @@ describe("PlexPersonView", () => { plexPerson.mockResolvedValue({ name: "Jane Doe", photo: null, + biography: "A celebrated actor.", + birthday: "1970-01-01", + place_of_birth: "Springfield", + known_for_department: "Acting", + also_known_as: ["Janie D"], + known_for: [card("kf1", "Famous Film")], movies: [card("m1", "Movie One")], shows: [card("s1", "Show One", "show")], }); @@ -91,13 +110,30 @@ describe("PlexPersonView", () => { expect(screen.getByText("plex.person.movies")).toBeTruthy(); // section headings (key passthrough) expect(screen.getByText("plex.person.shows")).toBeTruthy(); + // E5 S6 bio hero: department, biography, birth facts, aka. + expect(screen.getByText("Acting")).toBeTruthy(); + expect(screen.getByText("A celebrated actor.")).toBeTruthy(); + expect(screen.getByText(/1970-01-01/)).toBeTruthy(); + expect(screen.getByText(/plex\.person\.aka/)).toBeTruthy(); + expect(screen.getByText(/Janie D/)).toBeTruthy(); + // "Known for" rail renders its card. + expect(screen.getByText("plex.person.knownFor")).toBeTruthy(); + expect(screen.getAllByText("Famous Film").length).toBeGreaterThan(0); + fireEvent.click(screen.getByText("plex.person.filterBy")); expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] }); expect(plexPerson).toHaveBeenCalledWith("Jane Doe"); }); it("shows the empty message when the person has no titles", async () => { - plexPerson.mockResolvedValue({ name: "Nobody", photo: null, movies: [], shows: [] }); + plexPerson.mockResolvedValue({ + name: "Nobody", + photo: null, + also_known_as: [], + known_for: [], + movies: [], + shows: [], + }); wrap( api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); // This rail's media source (the Plex library) — names the abstraction the feed and Plex rails @@ -93,15 +92,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl }); const facets = facetsQ.data; - const [collSearch, setCollSearch] = useState(""); - const collSearchDeb = useDebounced(collSearch.trim(), 300); - const collectionsQ = useQuery({ - queryKey: qk.plexCollections("union", collSearchDeb), - queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), - enabled: !filters.collection, - }); - const collections = collectionsQ.data?.collections ?? []; - const fCount = plexFilterCount(filters); // 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). @@ -109,15 +99,14 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const anyActive = activeCount > 0; const patch = (p: Partial) => setFilters({ ...filters, ...p }); - const toggleIn = (arr: string[], v: string) => - arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]; const clearAll = usePlexClearFilters(); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; - const peopleActive = - filters.directors.length + filters.actors.length + filters.studios.length > 0; + // Directors + actors have no picker (they're added from the detail/cast surfaces) — this section + // just lists them as removable chips. Studios now have their own picker section (below). + const peopleActive = filters.directors.length + filters.actors.length > 0; // Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed, // so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved @@ -206,13 +195,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })} /> ))} - {filters.studios.map((s) => ( - patch({ studios: filters.studios.filter((x) => x !== s) })} - /> - ))} ), }, @@ -221,44 +203,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl { id: "collection", title: t("plex.filter.collection"), - body: filters.collection ? ( -
- patch({ collection: null, collectionTitle: null })} - /> -
- ) : ( -
- setCollSearch(e.target.value)} - placeholder={t("plex.filter.collectionSearch")} - className="mb-1.5 w-full rounded-lg border border-border bg-surface px-2 py-1 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" - /> -
- {collections.map((c) => ( - - ))} - {collections.length === 0 && ( - - {t("plex.filter.collectionEmpty")} - - )} -
-
+ body: ( + patch({ collection: id, collectionTitle: title })} + /> ), }, { @@ -284,27 +234,19 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl body: ( <> {filters.genres.length > 1 && ( - patch({ genreMode: m })} - /> +
+ patch({ genreMode: m })} + /> +
)} -
- {facets.genres.map((g) => ( - - ))} -
+ patch({ genres: next })} + /> ), }, @@ -380,24 +322,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl id: "contentrating", title: t("plex.filter.contentRating"), body: ( -
- {facets.content_ratings.map((c) => ( - - ))} -
+ patch({ contentRatings: next })} + /> + ), + }, + ] + : []), + ...(facets && facets.studios.length > 0 + ? [ + { + id: "studio", + title: t("plex.filter.studio"), + body: ( + patch({ studios: next })} + /> ), }, ] @@ -430,6 +376,58 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl ); } +// The collection filter (E5 S7 → combobox rework): a single-select combi-input matching the +// genre/studio pickers, its vocabulary searched SERVER-side (the union can run to hundreds). The +// generic owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on +// `open`) and maps collections to options. Picking sets the filter (id + title for the chip); clearing +// the chip drops it. +function CollectionPicker({ + value, + title, + onPick, +}: { + value: string | null | undefined; + title: string | null | undefined; + onPick: (id: string | null, title: string | null) => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const searchDeb = useDebounced(search.trim(), 300); + const q = useQuery({ + queryKey: qk.plexCollections("union", searchDeb), + queryFn: () => api.plexCollections(undefined, searchDeb || undefined), + enabled: open, // only fetch while the picker is open + }); + const options: ComboboxOption[] = (q.data?.collections ?? []).map((c) => ({ + value: c.id, + label: c.title, + count: c.child_count ?? undefined, + })); + + return ( + { + const id = next[0] ?? null; + const picked = options.find((o) => o.value === id); + onPick(id, id ? (picked?.label ?? title ?? id) : null); + }} + /> + ); +} + function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) { return ( diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx index 20261d36..f631aba8 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/components/plexCards.dom.test.tsx @@ -61,6 +61,26 @@ describe("PlexPosterCard a11y", () => { ); expect(screen.getByRole("button", { name: "plex.markUnwatched" })).toBeTruthy(); }); + + it("reveals the synopsis + rating on the hover scrim when the card carries them", () => { + render( + , + ); + expect(screen.getByText("A quiet synopsis.")).toBeTruthy(); + expect(screen.getByText("7.5")).toBeTruthy(); // rating.toFixed(1) + }); + + it("omits the scrim when the card has no synopsis or rating", () => { + render( + , + ); + expect(screen.queryByText(/synopsis/i)).toBeNull(); + }); }); describe("SeasonCard a11y", () => { diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 18595777..5fe5438e 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -1,7 +1,8 @@ import { useTranslation } from "react-i18next"; -import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Tv2 } from "lucide-react"; +import { CheckCircle2, Circle, Film, Info, ListPlus, Play, Star, Tv2 } from "lucide-react"; import { type CSSProperties } from "react"; import { formatRuntime } from "../lib/format"; +import { Carousel } from "./Carousel"; import type { PlexCard, PlexCastMember, PlexFilters, PlexSeasonDetail } from "../lib/api"; // Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared @@ -51,6 +52,7 @@ export function PlexPosterCard({ const inProgress = (card.position_seconds ?? 0) > 0 && card.status !== "watched"; const isPlayable = card.type !== "show"; // movies/episodes play; shows open the drill-down const watchedLabel = card.status === "watched" ? t("plex.markUnwatched") : t("plex.markWatched"); + const runtime = formatRuntime(card.duration_seconds); const pct = inProgress && card.duration_seconds ? Math.min(100, Math.round(((card.position_seconds ?? 0) / card.duration_seconds) * 100)) @@ -80,50 +82,15 @@ export function PlexPosterCard({ decoding="async" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" /> - {/* Hover affordance: a play overlay on every card (movies/episodes play; a show opens). */} -
-
- - - {card.type === "show" - ? t("plex.openShow") - : inProgress - ? t("plex.resume") - : t("plex.play")} - -
-
- {/* Quick watched toggle (movies/episodes), on hover. */} - {isPlayable && ( - + {/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */} + {card.playable && ( + )} - {/* Media info page (movies/episodes). */} - {isPlayable && ( - - )} - {/* Watched badge when not hovering (the toggle replaces it on hover). Only a playable card - has that toggle, so only there does the badge yield to it (reveal-idle: gone on touch, - where the toggle is always shown); a watched show keeps the badge as its sole marker. */} + {/* Rest-state watched badge (top-right). Yields to the hover quick-actions on a playable card + (reveal-idle: gone on touch where the toggle is always shown); a watched show keeps it. */} {card.status === "watched" && ( )} - {card.type === "show" && card.season_count != null && ( - - {t("plex.seasons", { count: card.season_count })} - - )} {/* Aggregate in-progress badge for a partially-watched show. */} {card.type === "show" && card.status === "in_progress" && ( {t("plex.inProgress")} )} - {card.playable && ( - + {/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */} + {card.type === "show" && card.season_count != null && ( + + {t("plex.seasons", { count: card.season_count })} + )} - {/* Type tag — tell a movie card from a show card at a glance (unified feed). */} {card.type === "show" ? : } + + {/* Hover reveal: a soft dim, a less-flat play ring, and a bottom scrim that slides up with the + synopsis + rating (the info you'd otherwise click through for). */} +
+
+
+
+ +
+
+ {(card.summary || card.rating != null) && ( +
+
+ {card.title} +
+
+ {(card.year || runtime) && ( + {[card.year, runtime].filter(Boolean).join(" · ")} + )} + {card.rating != null && ( + + + {card.rating.toFixed(1)} + + )} +
+ {card.summary && ( +

+ {card.summary} +

+ )} +
+ )} +
+ + {/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */} + {isPlayable && ( +
+ + +
+ )} + {pct > 0 && ( -
+
)} @@ -301,7 +333,7 @@ export function CastStrip({ style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }} >

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

-
+ {cast.map((c, i) => { const inner = ( <> @@ -316,12 +348,19 @@ export function CastStrip({
)}
+ {/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the + same text height and the row of circles sits on one baseline regardless of name length. */}
{c.name}
- {c.role &&
{c.role}
} + {c.role && ( +
+ {c.role} +
+ )} ); return clickable ? ( @@ -345,7 +384,7 @@ export function CastStrip({
); })} -
+ ); } @@ -364,7 +403,7 @@ export function RelatedStrip({ style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }} >

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

-
+ {related.map((r) => ( ))} -
+ ); } diff --git a/frontend/src/components/plexSubUrl.test.ts b/frontend/src/components/plexSubUrl.test.ts index 5a153802..4ed2f3fc 100644 --- a/frontend/src/components/plexSubUrl.test.ts +++ b/frontend/src/components/plexSubUrl.test.ts @@ -5,6 +5,7 @@ import { PLEX_SUB_URL as U, type Sub } from "./plexSubUrl"; describe("PLEX_SUB_URL encode", () => { it("mirrors each bookmarkable sub-view to ?v=:", () => { + expect(U.encode({ kind: "grid" })).toBe("browse"); // the flat "Browse all" grid (E5 S4) expect(U.encode({ kind: "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"); @@ -12,14 +13,15 @@ describe("PLEX_SUB_URL encode", () => { expect(U.encode({ kind: "playlist", id: 12 })).toBe("playlist:12"); }); - it("does NOT mirror the root grid or the ephemeral player (encode → null)", () => { - expect(U.encode({ kind: "grid" })).toBeNull(); + it("does NOT mirror the root Discover landing or the ephemeral player (encode → null)", () => { + expect(U.encode({ kind: "discover" })).toBeNull(); expect(U.encode({ kind: "player", id: "m9" })).toBeNull(); }); }); describe("PLEX_SUB_URL round-trip", () => { const cases: Sub[] = [ + { kind: "grid" }, { kind: "show", id: "s1" }, { kind: "season", showId: "s1", seasonId: "se2" }, { kind: "info", id: "m9" }, diff --git a/frontend/src/components/plexSubUrl.ts b/frontend/src/components/plexSubUrl.ts index a63e633a..381a7bd2 100644 --- a/frontend/src/components/plexSubUrl.ts +++ b/frontend/src/components/plexSubUrl.ts @@ -5,6 +5,7 @@ import { parsePositiveIntId } from "../lib/parseId"; // encode/decode round-trip (and its guards) can be unit-tested without pulling the whole component // (and its lazy hls.js player) into the test graph. export type Sub = + | { kind: "discover" } | { kind: "grid" } | { kind: "show"; id: string } | { kind: "season"; showId: string; seasonId: string } @@ -14,12 +15,16 @@ export type Sub = | { kind: "playlist"; id: number }; // Mirror the Plex sub-view to ?v=: so a show / season / movie-info / person / playlist page -// is bookmarkable. Grid (root) and the ephemeral player are not mirrored (encode → null). The person -// name is the whole tail after the first ":", so a name containing ":" round-trips. +// — and the flat browse grid — are bookmarkable. Discover is the ROOT landing (E5 S4), so it (and the +// ephemeral player) is not mirrored (encode → null); the grid gets `?v=browse` so "Browse all" is a +// distinct, shareable URL. The person name is the whole tail after the first ":", so a name containing +// ":" round-trips. export const PLEX_SUB_URL: SubviewUrl = { key: "v", encode: (v) => { switch (v.kind) { + case "grid": + return "browse"; case "show": return `show:${v.id}`; case "season": @@ -31,7 +36,7 @@ export const PLEX_SUB_URL: SubviewUrl = { case "playlist": return `playlist:${v.id}`; default: - return null; // grid + the ephemeral player are not URL-mirrored + return null; // discover (root) + the ephemeral player are not URL-mirrored } }, decode: (raw) => { @@ -39,6 +44,8 @@ export const PLEX_SUB_URL: SubviewUrl = { const kind = i === -1 ? raw : raw.slice(0, i); const arg = i === -1 ? "" : raw.slice(i + 1); switch (kind) { + case "browse": + return { kind: "grid" }; case "show": return arg ? { kind: "show", id: arg } : null; case "info": diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index c89071eb..7f9b0d1d 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -15,11 +15,13 @@ import { DetailHero, epLabel, Filterable, - useArtBackdrop, + tmdbBackdropUrl, + useCrossfadeBackdrop, useDetailPrefs, WatchedToggle, } from "../lib/plexDetailUi"; import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; +import { Carousel } from "./Carousel"; import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; import { LoadingState, StateMessage } from "./QueryState"; @@ -106,6 +108,15 @@ export function PlexPersonView({ const { t } = useTranslation(); const qc = useQueryClient(); const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) }); + // E5 S8: cross-dissolve the backdrop through the person's films' art (the touch the roadmap calls + // out). Reuses the shared art-backdrop / crossfade prefs the detail pages own — no per-page control. + const { artBg, crossfade } = useDetailPrefs(); + const pd = q.data; + useCrossfadeBackdrop( + [...(pd?.known_for ?? []), ...(pd?.movies ?? []), ...(pd?.shows ?? [])].map((c) => c.art), + artBg, + crossfade, + ); // Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload // (both the movies and shows arrays — an id matches only one), then persist + resync via the shared @@ -119,6 +130,9 @@ export function PlexPersonView({ ...old, movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)), shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)), + known_for: old.known_for.map((k) => + k.id === card.id ? { ...k, status: next } : k, + ), } : old, ), @@ -130,7 +144,7 @@ export function PlexPersonView({ const section = (titleKey: string, cards: PlexCard[]) => cards.length > 0 ? ( -
+

{t(titleKey, { count: cards.length })}

{cards.map((c) => ( @@ -167,33 +181,78 @@ export function PlexPersonView({ ) : (
- {/* Hero: headshot + name + a "filter the library by this person" action. */} -
-
- {d.photo ? ( - - ) : ( -
- + {/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */} +
+
+
+ {d.photo ? ( + + ) : ( +
+ +
+ )} +
+
+

{d.name}

+ {d.known_for_department && ( +

{d.known_for_department}

+ )} +

+ {t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })} +

+
+ {(d.birthday || d.place_of_birth) && ( +

+ {t("plex.person.born")}:{" "} + {[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")} +

+ )} + {d.deathday && ( +

+ {t("plex.person.died")}: {d.deathday} +

+ )} + {d.also_known_as.length > 0 && ( +

+ {t("plex.person.aka")}:{" "} + {d.also_known_as.slice(0, 4).join(", ")} +

+ )}
- )} + +
-
-

{d.name}

-

- {t("plex.person.credits", { - movies: d.movies.length, - shows: d.shows.length, - })} + {d.biography && ( +

+ {d.biography}

- -
+ )}
+ + {/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */} + {d.known_for.length > 0 && ( +
+ + {d.known_for.map((c) => ( +
+ onOpenCard(c)} + onInfo={() => onInfoCard(c)} + onToggleWatched={toggle} + /> +
+ ))} +
+
+ )} + {empty ? (

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

) : ( @@ -249,8 +308,25 @@ export function PlexShowView({ const showLib = show?.library ?? undefined; const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id)); const watched = show?.status === "watched"; - const { artBg, showCast, showRelated, toggleArtBg, toggleCast, toggleRelated } = useDetailPrefs(); - useArtBackdrop(show?.art, artBg); + const { + artBg, + showCast, + showRelated, + crossfade, + toggleArtBg, + toggleCast, + toggleRelated, + toggleCrossfade, + } = useDetailPrefs(); + useCrossfadeBackdrop( + [ + show?.art, + ...(show?.tmdb?.backdrops ?? []).map(tmdbBackdropUrl), + ...(d?.related ?? []).map((r) => r.art), + ], + artBg, + crossfade, + ); async function markAll(w: boolean) { setBusy(true); try { @@ -296,6 +372,8 @@ export function PlexShowView({ 0} @@ -492,8 +570,12 @@ export function PlexSeasonView({ // seasonKeys stays only for the season-scoped "add to playlist" action. const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id)); const watched = se?.status === "watched"; - const { artBg, showCast, toggleArtBg, toggleCast } = useDetailPrefs(); - useArtBackdrop(d?.show.art, artBg); + const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs(); + useCrossfadeBackdrop( + [d?.show.art, ...(d?.show.tmdb?.backdrops ?? []).map(tmdbBackdropUrl)], + artBg, + crossfade, + ); async function markAll(w: boolean) { setBusy(true); try { @@ -539,6 +621,8 @@ export function PlexSeasonView({ patch({ studios: filters.studios.filter((x) => x !== s) }), }); } + for (const k of filters.keywords) { + out.push({ + key: `keyword:${k}`, + label: k, + remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }), + }); + } if (filters.collection) { out.push({ key: "collection", diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index deed2f72..f476fe5f 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -19,5 +19,11 @@ "demoSharedNotice": "You're in the shared demo account. Everything you do here — playlists, hidden videos, settings — is communal and may be changed by other demo visitors at the same time.", "backToTop": "Back to top", "retry": "Retry", - "loadError": "Couldn't load this — check your connection and try again." + "loadError": "Couldn't load this — check your connection and try again.", + "seeAll": "See all", + "scrollLeft": "Scroll left", + "scrollRight": "Scroll right", + "search": "Search", + "remove": "Remove", + "noResults": "No matches" } diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index f4cd4060..1949c832 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -70,7 +70,9 @@ "collection": "Collection", "collectionSearch": "Search collections…", "collectionEmpty": "No collections", - "matchLabel": "Match" + "matchLabel": "Match", + "studio": "Studio", + "moreRefine": "{{count}} more — refine with search" }, "count": "{{count}} titles", "searchCount": "{{count}} matches", @@ -186,6 +188,9 @@ "prefArtBg": "Faint backdrop", "prefCast": "Cast & crew", "prefRelated": "Related shows", + "prefFacts": "Details", + "prefKeywords": "Keywords", + "prefCrew": "Crew", "stripSource": { "collection": "Collections", "imdb": "IMDb", @@ -204,7 +209,22 @@ "filterRating": "Show titles rated {{n}}+", "filterActor": "Show titles with {{name}}", "viewPerson": "View {{name}}'s filmography", - "browseCollection": "Browse collection →" + "browseCollection": "Browse collection →", + "audience": "Audience", + "keywords": "Keywords", + "crew": "Crew", + "filterKeyword": "Filter by \"{{name}}\"", + "facts": { + "title": "Details", + "release": "Released", + "runtime": "Runtime", + "cert": "Rating", + "budget": "Budget", + "revenue": "Revenue", + "studios": "Studios", + "countries": "Countries" + }, + "prefCrossfade": "Crossfade backdrop" }, "playable": { "direct": "Plays directly in the browser", @@ -252,6 +272,24 @@ "filterBy": "Filter library by {{name}}", "movies": "Movies ({{count}})", "shows": "Shows ({{count}})", - "empty": "No titles found for this person." + "empty": "No titles found for this person.", + "born": "Born", + "died": "Died", + "aka": "Also known as", + "knownFor": "Known for" + }, + "view": { + "label": "View", + "browse": "Browse" + }, + "discover": { + "tab": "Discover", + "continueWatching": "Continue watching", + "recentlyAdded": "Recently added", + "surprise": "Surprise me", + "reshuffle": "Reshuffle", + "byGenre": "Browse by genre", + "becauseWatched": "Because you watched {{title}}", + "empty": "Nothing to discover yet — run a Plex sync from the admin Config page." } } diff --git a/frontend/src/i18n/locales/en/scheduler.json b/frontend/src/i18n/locales/en/scheduler.json index abaa7793..79b58399 100644 --- a/frontend/src/i18n/locales/en/scheduler.json +++ b/frontend/src/i18n/locales/en/scheduler.json @@ -72,7 +72,8 @@ "plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.", "plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.", "plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.", - "audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded." + "audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded.", + "tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again." }, "jobs": { "rss_poll": "RSS poll (new uploads)", @@ -89,6 +90,7 @@ "plex_sync": "Plex library sync", "plex_watch_sync": "Plex watch sync (incremental)", "plex_watch_reconcile": "Plex watch reconcile (full)", + "tmdb_enrich": "TMDB enrichment (Plex)", "audit_gc": "Audit-log cleanup" }, "queue": { diff --git a/frontend/src/i18n/locales/hu/common.json b/frontend/src/i18n/locales/hu/common.json index a3386834..b40d4281 100644 --- a/frontend/src/i18n/locales/hu/common.json +++ b/frontend/src/i18n/locales/hu/common.json @@ -19,5 +19,11 @@ "demoSharedNotice": "Egy közös demo fiókban vagy. Minden, amit itt csinálsz — lejátszási listák, elrejtett videók, beállítások — közös, és más demo látogatók egyszerre módosíthatják.", "backToTop": "Vissza a tetejére", "retry": "Újra", - "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra." + "loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra.", + "seeAll": "Összes", + "scrollLeft": "Görgetés balra", + "scrollRight": "Görgetés jobbra", + "search": "Keresés", + "remove": "Eltávolítás", + "noResults": "Nincs találat" } diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index eeb7dd58..40f19229 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -70,7 +70,9 @@ "collection": "Kollekció", "collectionSearch": "Kollekciók keresése…", "collectionEmpty": "Nincs kollekció", - "matchLabel": "Egyezés" + "matchLabel": "Egyezés", + "studio": "Stúdió", + "moreRefine": "még {{count}} — finomíts kereséssel" }, "count": "{{count}} cím", "searchCount": "{{count}} találat", @@ -186,6 +188,9 @@ "prefArtBg": "Halvány háttér", "prefCast": "Szereplők", "prefRelated": "Kapcsolódó sorozatok", + "prefFacts": "Részletek", + "prefKeywords": "Kulcsszavak", + "prefCrew": "Stáb", "stripSource": { "collection": "Kollekciók", "imdb": "IMDb", @@ -204,7 +209,22 @@ "filterRating": "{{n}}+ pontos címek", "filterActor": "Címek {{name}} szereplésével", "viewPerson": "{{name}} filmográfiája", - "browseCollection": "Kollekció böngészése →" + "browseCollection": "Kollekció böngészése →", + "audience": "Közönség", + "keywords": "Kulcsszavak", + "crew": "Stáb", + "filterKeyword": "Szűrés erre: „{{name}}”", + "facts": { + "title": "Részletek", + "release": "Megjelenés", + "runtime": "Hossz", + "cert": "Korhatár", + "budget": "Költségvetés", + "revenue": "Bevétel", + "studios": "Stúdiók", + "countries": "Országok" + }, + "prefCrossfade": "Áttűnő háttér" }, "playable": { "direct": "Közvetlenül játszható a böngészőben", @@ -252,6 +272,24 @@ "filterBy": "Szűrés erre: {{name}}", "movies": "Filmek ({{count}})", "shows": "Sorozatok ({{count}})", - "empty": "Nincs találat ehhez a személyhez." + "empty": "Nincs találat ehhez a személyhez.", + "born": "Született", + "died": "Elhunyt", + "aka": "Más néven", + "knownFor": "Ismert szerepei" + }, + "view": { + "label": "Nézet", + "browse": "Böngészés" + }, + "discover": { + "tab": "Felfedezés", + "continueWatching": "Folytatás", + "recentlyAdded": "Nemrég hozzáadva", + "surprise": "Lepj meg", + "reshuffle": "Újrakeverés", + "byGenre": "Böngészés műfaj szerint", + "becauseWatched": "Mert megnézted: {{title}}", + "empty": "Még nincs mit felfedezni — futtass egy Plex-szinkront az admin Konfiguráció oldalról." } } diff --git a/frontend/src/i18n/locales/hu/scheduler.json b/frontend/src/i18n/locales/hu/scheduler.json index 4ab4d883..e2b2b955 100644 --- a/frontend/src/i18n/locales/hu/scheduler.json +++ b/frontend/src/i18n/locales/hu/scheduler.json @@ -72,7 +72,8 @@ "plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.", "plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.", "plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza.", - "audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul." + "audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul.", + "tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül." }, "jobs": { "rss_poll": "RSS-lekérdezés (új feltöltések)", @@ -89,6 +90,7 @@ "plex_sync": "Plex-könyvtár szinkron", "plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)", "plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)", + "tmdb_enrich": "TMDB-dúsítás (Plex)", "audit_gc": "Napló-karbantartás" }, "queue": { diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 9257b02f..5812722d 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -42,6 +42,8 @@ export interface PlexCard { year?: number | null; duration_seconds?: number | null; thumb: string; + art?: string | null; // wide backdrop (movies/shows) for rails + crossfading hero; null when no art_key + rating?: number | null; // Plex audience score (~IMDb), shown on the card hover-overview playable?: string; // direct | remux | transcode status?: string; // new | watched | hidden position_seconds?: number; @@ -50,7 +52,29 @@ export interface PlexCard { episode_number?: number | null; // episode show_title?: string | null; // episode (in a mixed playlist) show_id?: string | null; // episode: the show's rating_key (for grouping a playlist by show/season) - summary?: string | null; // episode + summary?: string | null; // movie/show hover-overview synopsis (trimmed) + episode summary +} +// TMDB enrichment block (E5 S2), served from OUR DB (never a live TMDB call) on a movie/show detail. +// null until the row has been enriched. Powers the S5 enriched detail page: multi-source ratings row, +// facts sidebar, keyword chips (→ filter), and the full crew. +export interface PlexTmdbCrew { + name: string; + job: string; + tmdb_id?: number | null; + profile_path?: string | null; // raw TMDB path — we don't proxy crew headshots, so name+job only +} +export interface PlexTmdb { + tmdb_id?: number | null; + rating?: number | null; // TMDb user score (0–10) + vote_count?: number | null; + budget?: number | null; // USD, movies only + revenue?: number | null; // USD, movies only + keywords: string[]; + crew: PlexTmdbCrew[]; + studios: string[]; + production_countries: string[]; + recommendations: { tmdb_id?: number | null; title?: string | null; in_library: boolean }[]; + backdrops: string[]; } // Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that // also matches episodes (the grouped "Episodes" section). @@ -96,6 +120,7 @@ export interface PlexShowDetail { first?: PlexCard | null; episode_count: number; collection_keys: string[]; + tmdb?: PlexTmdb | null; }; seasons: PlexSeasonDetail[]; related: PlexCard[]; @@ -147,6 +172,7 @@ export interface PlexItemDetail { // `source` buckets the strip so each type can be shown/hidden independently on the info page: // "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart". collections?: { id: string; title: string; source: string; items: PlexCard[] }[]; + tmdb?: PlexTmdb | null; // movies only (episodes aren't enriched) } export interface PlexCollection { id: string; @@ -185,6 +211,15 @@ export interface PlexPersonCard { export interface PlexPersonDetail { name: string; photo?: string | null; // cached headshot, or null (placeholder) + // TMDB bio (E5 S6), lazily fetched on first open, then served from the row. Null when the person has + // no linked tmdb_person_id (never appeared in a TMDB-enriched title) or TMDB has no data. + biography?: string | null; + birthday?: string | null; // "YYYY-MM-DD" + deathday?: string | null; + place_of_birth?: string | null; + known_for_department?: string | null; + also_known_as: string[]; + known_for: PlexCard[]; // their most-popular credits that are IN our library (deep-linkable) movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person shows: PlexCard[]; // enabled-library shows, same } @@ -203,6 +238,7 @@ export interface PlexFilters { actors: string[]; // OR (any) by default, or AND (all) via actorMode 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 } @@ -212,6 +248,7 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = { directors: [], actors: [], studios: [], + keywords: [], }; export function plexFilterCount(f: PlexFilters): number { return ( @@ -224,6 +261,7 @@ export function plexFilterCount(f: PlexFilters): number { f.directors.length + f.actors.length + f.studios.length + + f.keywords.length + (f.collection ? 1 : 0) ); } @@ -244,11 +282,13 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void { if (f.actors?.length) u.set("actors", f.actors.join(",")); 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); } export interface PlexFacets { genres: { value: string; count: number }[]; content_ratings: { value: string; count: number }[]; + studios: { value: string; count: number }[]; // studio vocabulary for the sidebar picker (E5 S7) year_min: number | null; year_max: number | null; rating_max: number | null; @@ -261,6 +301,17 @@ export interface PlexPlaySession { start_s: number; } +// Discover surface (E5 S4): the consolidated rail payload. Every rail is a plain PlexCard list the +// dedicated Discover landing renders as a horizontal Carousel; `because_you_watched` is null when the +// user has no TMDB-enriched watch history (rail hidden). Served from DB — no live Plex/TMDB call. +export interface PlexDiscover { + scope: string; + continue_watching: PlexCard[]; + recently_added: PlexCard[]; + genres: { value: string; count: number }[]; // top genres → tap a tile to filter the grid + because_you_watched: { anchor: { id: string; title: string }; items: PlexCard[] } | null; +} + // Two-way Plex watch-state sync link status (owner account; Phase A). export interface PlexWatchLink { linked: boolean; @@ -409,6 +460,18 @@ 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 => { + const u = new URLSearchParams({ scope }); + if (lang === "hu") u.set("lang", "hu"); + 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[] }> => { + const u = new URLSearchParams({ scope }); + if (limit) u.set("limit", String(limit)); + return req(`/api/plex/surprise?${u.toString()}`); + }, plexShow: (id: string, lang?: string): Promise => req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`), // Mark a whole show / season watched or unwatched (all its episodes) for the current user. diff --git a/frontend/src/lib/plexBackdrop.dom.test.tsx b/frontend/src/lib/plexBackdrop.dom.test.tsx new file mode 100644 index 00000000..c7becf77 --- /dev/null +++ b/frontend/src/lib/plexBackdrop.dom.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom +// +// E5 S8: the crossfading art backdrop hook. Covers the pieces that don't need real layout — that a +// pool paints #root + isolates it (so the z-index:-1 overlay can paint above #root's own background), +// creates exactly one overlay layer when it may animate, and HARD-OFFS the rotation (no overlay, first +// image only) under prefers-reduced-motion, a single image, or when disabled. matchMedia is stubbed +// per-test; the hook reads it at effect time. +import { describe, it, expect, afterEach, vi } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { useCrossfadeBackdrop } from "./plexDetailUi"; + +function mockReducedMotion(reduce: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("prefers-reduced-motion") ? reduce : false, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as unknown as typeof window.matchMedia; +} + +function Harness({ + arts, + enabled = true, + animate = true, +}: { + arts: (string | null | undefined)[]; + enabled?: boolean; + animate?: boolean; +}) { + useCrossfadeBackdrop(arts, enabled, animate); + return null; +} + +function root() { + return document.getElementById("root")!; +} + +// The hook paints #root, so it must exist in the document (main.tsx renders into it in the real app). +function withRoot() { + const el = document.createElement("div"); + el.id = "root"; + document.body.appendChild(el); + return el; +} + +afterEach(() => { + cleanup(); + document.getElementById("root")?.remove(); +}); + +const overlays = () => + Array.from(root().children).filter((c) => (c as HTMLElement).style.zIndex === "-1"); + +describe("useCrossfadeBackdrop", () => { + it("paints + isolates #root and creates one overlay when it can animate", () => { + withRoot(); + mockReducedMotion(false); + render(); + expect(root().style.backgroundImage).toContain('url("/a.jpg")'); + expect(root().style.isolation).toBe("isolate"); // needed for the z-index:-1 overlay to show + expect(overlays().length).toBe(1); + }); + + it("hard-offs the rotation under prefers-reduced-motion (static, no overlay)", () => { + withRoot(); + mockReducedMotion(true); + render(); + expect(root().style.backgroundImage).toContain('url("/a.jpg")'); // first image still painted + expect(overlays().length).toBe(0); // but no crossfade layer + }); + + it("stays static for a single image (no overlay)", () => { + withRoot(); + mockReducedMotion(false); + render(); + expect(root().style.backgroundImage).toContain('url("/only.jpg")'); + expect(overlays().length).toBe(0); + }); + + it("does not animate when animate=false (crossfade pref off)", () => { + withRoot(); + mockReducedMotion(false); + render(); + expect(root().style.backgroundImage).toContain('url("/a.jpg")'); + expect(overlays().length).toBe(0); + }); + + it("clears #root when disabled and removes the overlay on unmount", () => { + withRoot(); + mockReducedMotion(false); + const { rerender, unmount } = render(); + expect(root().style.backgroundImage).toBe(""); + expect(overlays().length).toBe(0); + rerender(); + expect(overlays().length).toBe(1); + unmount(); + expect(overlays().length).toBe(0); // cleaned up + expect(root().style.isolation).toBe(""); + }); +}); diff --git a/frontend/src/lib/plexDetailUi.tsx b/frontend/src/lib/plexDetailUi.tsx index cf4b0199..c1047225 100644 --- a/frontend/src/lib/plexDetailUi.tsx +++ b/frontend/src/lib/plexDetailUi.tsx @@ -24,18 +24,30 @@ export function useDetailPrefs() { const [artBg, setArtBg] = usePref("plexInfoArtBg", true); const [showCast, setShowCast] = usePref("plexInfoCast", true); const [showRelated, setShowRelated] = usePref("plexInfoRelated", true); + // E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent + // of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle. + const [crossfade, setCrossfade] = usePref("plexInfoArtCrossfade", true); const savePref = useSavePref(); return { artBg, showCast, showRelated, + crossfade, savePref, // the raw persister for callers with extra per-user prefs (e.g. strip sources) toggleArtBg: () => setArtBg(!artBg), toggleCast: () => setShowCast(!showCast), toggleRelated: () => setShowRelated(!showRelated), + toggleCrossfade: () => setCrossfade(!crossfade), }; } +// A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only +// frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the +// crossfade even on a lone movie with no collection. +export function tmdbBackdropUrl(path: string): string { + return `https://image.tmdb.org/t/p/w1280${path}`; +} + // A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared // by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional // `title` gives a hover hint (e.g. "Filter by 1998"); callers that don't need it just omit it. @@ -62,39 +74,109 @@ export function Filterable({ ); } -// Paint the item's art as a faint FULL-VIEWPORT backdrop (HTPC-style) so the glass chrome floats over -// it. Painted on the app root (#root) — which is exactly the viewport and sits BEHIND the transparent -// nav / header / content — so the art fills the whole page edge-to-edge (under the rail + floating -// header too), not just the inset
box. (A `position:fixed` layer at negative z-index does NOT -// work here: the body's opaque background is painted over it, so it's invisible; #root's background, -// being a real ancestor of the content, is the reliable full-viewport layer.) `attachment:fixed` -// keeps it viewport-anchored while the content scrolls. Cleared on unmount / disable / no art. -export function useArtBackdrop(art: string | null | undefined, enabled: boolean) { +// A faint full-viewport art backdrop that slowly cross-dissolves through a pool of images (E5 S8). +// +// Painting layer: the SETTLED image lives on the app root (#root) background — the proven full-viewport +// layer (#root is exactly the viewport, transparent by default so the art shows through the glassy nav +// / header / content that float over it; a fixed layer at negative z-index that is NOT a #root child is +// hidden by the body scheme-background). The incoming image cross-fades in on an OVERLAY layer that IS +// a `z-index:-1` child of #root: it paints ABOVE #root's own background (the settled art) but BELOW the +// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to +// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node. +// +// The rotation is HARD-OFF under prefers-reduced-motion (shows only the first image, no movement) and +// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored +// while the content scrolls. Cleared on unmount / disable / empty pool. +const BACKDROP_FADE_MS = 1600; +const BACKDROP_INTERVAL_MS = 7000; +const BACKDROP_DIM = + "linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), " + + "color-mix(in srgb, var(--bg) 64%, transparent))"; + +export function useCrossfadeBackdrop( + arts: (string | null | undefined)[], + enabled: boolean, + animate = true, +) { + // Deduped, non-null pool, collapsed to a string dep so the effect only re-runs on a real change. + const key = Array.from(new Set(arts.filter((a): a is string => !!a))).join("|"); useLayoutEffect(() => { - const el = document.getElementById("root"); - if (!el) return; - const s = el.style; - const clear = () => { - s.backgroundImage = ""; - s.backgroundSize = ""; - s.backgroundPosition = ""; - s.backgroundRepeat = ""; - s.backgroundAttachment = ""; + const root = document.getElementById("root"); + if (!root) return; + const paint = (url: string) => { + root.style.backgroundImage = `${BACKDROP_DIM}, url("${url}")`; + root.style.backgroundSize = "cover"; + root.style.backgroundPosition = "center 20%"; + root.style.backgroundRepeat = "no-repeat"; + root.style.backgroundAttachment = "fixed"; + // #root must be a stacking context for the z-index:-1 crossfade overlay to paint ABOVE #root's + // own background (the settled art) rather than behind it — without this the overlay is invisible. + root.style.isolation = "isolate"; }; - if (enabled && art) { - // The faint --bg overlay keeps it subtle and text-safe (same tuning the old paint used). - s.backgroundImage = - `linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` + - `color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`; - s.backgroundSize = "cover"; - s.backgroundPosition = "center 20%"; - s.backgroundRepeat = "no-repeat"; - s.backgroundAttachment = "fixed"; - } else { + const clear = () => { + root.style.backgroundImage = ""; + root.style.backgroundSize = ""; + root.style.backgroundPosition = ""; + root.style.backgroundRepeat = ""; + root.style.backgroundAttachment = ""; + root.style.isolation = ""; + }; + + const pool = key ? key.split("|") : []; + if (!enabled || pool.length === 0) { clear(); + return clear; // and clear again on unmount / dep-change (idempotent) } - return clear; - }, [art, enabled]); + paint(pool[0]!); + + const reduce = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + // Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise + // #root keeps the painted backdrop after leaving the page (e.g. switching to another module). + if (!animate || reduce || pool.length < 2) return clear; + + const layer = document.createElement("div"); + Object.assign(layer.style, { + position: "fixed", + inset: "0", + zIndex: "-1", + backgroundSize: "cover", + backgroundPosition: "center 20%", + backgroundRepeat: "no-repeat", + backgroundAttachment: "fixed", + opacity: "0", + pointerEvents: "none", + }); + root.appendChild(layer); + + let idx = 0; + let fadeTimer: ReturnType | undefined; + const tick = () => { + const next = (idx + 1) % pool.length; + layer.style.transition = "none"; + layer.style.opacity = "0"; + layer.style.backgroundImage = `${BACKDROP_DIM}, url("${pool[next]}")`; + requestAnimationFrame(() => { + layer.style.transition = `opacity ${BACKDROP_FADE_MS}ms ease`; + layer.style.opacity = "1"; + }); + fadeTimer = setTimeout(() => { + paint(pool[next]!); // settle the new image on #root, then reset the overlay (no visible change) + layer.style.transition = "none"; + layer.style.opacity = "0"; + idx = next; + }, BACKDROP_FADE_MS); + }; + const interval = setInterval(tick, BACKDROP_INTERVAL_MS); + return () => { + clearInterval(interval); + if (fadeTimer) clearTimeout(fadeTimer); + layer.remove(); + clear(); + }; + }, [key, enabled, animate]); } export function DetailCustomizeMenu({ @@ -106,6 +188,8 @@ export function DetailCustomizeMenu({ showRelated, onToggleRelated, hasRelated, + crossfade, + onToggleCrossfade, overlay, extra, }: { @@ -117,6 +201,10 @@ export function DetailCustomizeMenu({ showRelated?: boolean; onToggleRelated?: () => void; hasRelated?: boolean; + // Crossfade toggle (E5 S8) — offered only when the art backdrop itself is on; omitted where a page + // doesn't wire it (e.g. the season page reuses the show's backdrop prefs without its own control). + crossfade?: boolean; + onToggleCrossfade?: () => void; overlay?: boolean; // white-on-dark button styling when floated over the player extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets) }) { @@ -148,6 +236,13 @@ export function DetailCustomizeMenu({ style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }} > + {artBg && onToggleCrossfade && ( + + )} {hasCast && ( )} diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index ee1cfaf5..08d69d50 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -93,6 +93,12 @@ export const qk = { plex: () => ["plex"] as const, plexWatchLink: () => ["plex-watch-link"] as const, plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], + // Discover surface (E5 S4): the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches + // 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), plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest], // Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN // metadata is cached separately. Omitting it (e.g. for a prefix invalidation) still matches both. diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 6adec696..8d5b47f0 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,22 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.71.0", + date: "2026-08-21", + summary: + "A much richer Plex experience: a Discover home, detailed movie/show and cast pages with ratings, facts, keywords and crew, a gently crossfading backdrop, and searchable filters.", + features: [ + "New Plex “Discover” home. Instead of opening straight into the full grid, Plex now greets you with rows — Continue watching, Recently added, browse-by-genre tiles, a “Surprise me” shuffle, and “Because you watched …” recommendations. The full grid is still one tap away under “Browse”.", + "Movie and show pages are much richer: several rating sources side by side (IMDb, TMDb, audience), a facts panel (release, runtime, certificate, budget, revenue, studios, countries), tappable keyword chips that filter your library, and the full key crew — click a name to open that person’s page.", + "Cast and crew pages now show a biography, birth facts, and a “Known for” row of that person’s titles you have in your library.", + "Detail and person pages gain a slow, gently crossfading backdrop drawn from the title’s artwork. It respects a reduced-motion preference and can be switched off from the page’s customise menu.", + "Clearer Plex filters: type-to-search pickers for genre, age rating and a new studio filter, plus the collection filter — each shows your choices as removable chips and is fully keyboard-operable.", + ], + chores: [ + "All the enriched data — ratings, keywords, crew, budgets, biographies and recommendations — is fetched from The Movie Database in the background and stored locally, so pages stay fast and keep working even when TMDB is unavailable. Reviewed with a standard review pass and a multi-agent deep review.", + ], + }, { version: "0.70.0", date: "2026-08-20", diff --git a/frontend/src/lib/useScrollEdges.ts b/frontend/src/lib/useScrollEdges.ts new file mode 100644 index 00000000..0f86c0fa --- /dev/null +++ b/frontend/src/lib/useScrollEdges.ts @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; + +// The shared scroll-affordance core behind `useScrollFade` (vertical edge fade) and the Carousel's +// `useHScroll` (horizontal scroll buttons). Both need the exact same machinery — track whether a +// scroller is clipped at the START and END of ONE axis, watching the scroller AND its children so the +// edges re-evaluate even when no `scroll` event fires (a rail whose cards load in later grows +// scrollWidth/scrollHeight silently; a Suspense fallback gets SWAPPED for the real content). This is +// that machinery, parameterised by axis; the two hooks just map `start`/`end` to their own shape. +// +// Uses a node-state callback ref (not useRef) so the effect (re)runs whenever the element attaches — +// e.g. a SidePanel that mounts collapsed and only renders its scroll body once expanded still wires up. +// `start`/`end` = there is more content before / after the visible window ("y": top/bottom, "x": +// left/right). The boolean-diff bail-out matters: `scroll` fires per frame and the page scroller's hook +// sits above the whole tree, so returning a new object each frame would re-render everything. +export function useScrollEdges(axis: "x" | "y"): { + ref: (node: HTMLElement | null) => void; + el: HTMLElement | null; + start: boolean; + end: boolean; +} { + const [el, setEl] = useState(null); + const [edge, setEdge] = useState({ start: false, end: false }); + + useEffect(() => { + if (!el) return; + const update = () => { + const pos = axis === "x" ? el.scrollLeft : el.scrollTop; + const client = axis === "x" ? el.clientWidth : el.clientHeight; + const scroll = axis === "x" ? el.scrollWidth : el.scrollHeight; + const start = pos > 1; + const end = pos + client < scroll - 1; + setEdge((prev) => (prev.start === start && prev.end === end ? prev : { start, end })); + }; + update(); + el.addEventListener("scroll", update, { passive: true }); + // Watch the scroller AND its content: content that grows/shrinks/decrypts/filters changes neither + // the scroller's own box nor its scroll offset, so without the children nothing re-evaluates. + const ro = new ResizeObserver(update); + ro.observe(el); + for (const child of Array.from(el.children)) ro.observe(child); + // Children get SWAPPED (a lazy page replacing its Suspense fallback), and a ResizeObserver holding a + // detached node simply never fires again — so follow the child list, observing/unobserving exactly + // the nodes named in each record (O(1) per change, not a re-observe of a long list on every append). + const mo = new MutationObserver((records) => { + for (const rec of records) { + for (const node of Array.from(rec.addedNodes)) + if (node instanceof Element) ro.observe(node); + for (const node of Array.from(rec.removedNodes)) + if (node instanceof Element) ro.unobserve(node); + } + update(); + }); + mo.observe(el, { childList: true }); + return () => { + el.removeEventListener("scroll", update); + ro.disconnect(); + mo.disconnect(); + }; + }, [el, axis]); + + return { ref: setEl, el, start: edge.start, end: edge.end }; +} diff --git a/frontend/src/lib/useScrollFade.ts b/frontend/src/lib/useScrollFade.ts index a20a0ed4..d64d879c 100644 --- a/frontend/src/lib/useScrollFade.ts +++ b/frontend/src/lib/useScrollFade.ts @@ -1,15 +1,15 @@ -import { useEffect, useState, type CSSProperties } from "react"; +import { type CSSProperties } from "react"; +import { useScrollEdges } from "./useScrollEdges"; -// Scroll-affordance for a scroll container (typically one whose native scrollbar is hidden with -// `.no-scrollbar`, but the page scroller keeps its bar and takes the fade anyway): -// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge -// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref -// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback -// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that -// mounts collapsed and only renders its scroll body once expanded still wires up correctly. -// `fadeTop` gates the top edge only. Pass false when the scroller's top is a sticky element (a -// table's sticky header): fading there would eat into the header instead of hinting at content, and -// the header would visibly change as the fade turns on with the first scroll. +// Vertical scroll-affordance for a scroll container (typically one whose native scrollbar is hidden +// with `.no-scrollbar`, but the page scroller keeps its bar and takes the fade anyway): fades the +// top/bottom edge of the content to hint there's more to scroll, but only on the edge that's actually +// clipped (no fade at a boundary, none when it all fits). Returns a callback ref to attach to the +// scroller and the mask `style` to spread onto it. The scroller-and-children watching machinery lives +// in the shared `useScrollEdges` (the horizontal twin, Carousel's `useHScroll`, uses the same core). +// `fadeTop` gates the top edge only. Pass false when the scroller's top is a sticky element (a table's +// sticky header): fading there would eat into the header instead of hinting at content, and the header +// would visibly change as the fade turns on with the first scroll. export function useScrollFade( fadePx = 20, fadeTop = true, @@ -17,52 +17,9 @@ export function useScrollFade( ref: (node: HTMLElement | null) => void; style: CSSProperties; } { - const [el, setEl] = useState(null); - const [fade, setFade] = useState({ up: false, down: false }); - - useEffect(() => { - if (!el) return; - // Bail out when neither edge changed: `scroll` fires per frame, and the page scroller's - // hook sits above the whole page tree, so a new object here would re-render on every frame - // of every scroll. The two booleans only flip at the ends of the range. - const update = () => { - const up = el.scrollTop > 1; - const down = el.scrollTop + el.clientHeight < el.scrollHeight - 1; - setFade((prev) => (prev.up === up && prev.down === down ? prev : { up, down })); - }; - update(); - el.addEventListener("scroll", update, { passive: true }); - // Watch the scroller AND its content. Content that grows or shrinks — a lazy page loading in, - // a thread's messages decrypting, a list being filtered — changes neither the scroller's own - // box nor its scrollTop, so without the children nothing would re-evaluate the edges. - const ro = new ResizeObserver(update); - ro.observe(el); - for (const child of Array.from(el.children)) ro.observe(child); - // Children get SWAPPED, and a ResizeObserver holding a detached node simply never fires - // again. Measured: the Plex page opened with no bottom fade over a 3119px list, because the - // node observed at mount was the Suspense fallback the real page then replaced. (Same - // lazy-page race that broke BackToTop.) So follow the child list: the records name exactly - // which nodes arrived and left, which keeps this O(1) per change rather than re-observing - // every child of a long list on each append. - const mo = new MutationObserver((records) => { - for (const rec of records) { - for (const node of Array.from(rec.addedNodes)) - if (node instanceof Element) ro.observe(node); - for (const node of Array.from(rec.removedNodes)) - if (node instanceof Element) ro.unobserve(node); - } - update(); - }); - mo.observe(el, { childList: true }); - return () => { - el.removeEventListener("scroll", update); - ro.disconnect(); - mo.disconnect(); - }; - }, [el]); - - const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up && fadeTop ? fadePx : 0}px, #000 calc(100% - ${ - fade.down ? fadePx : 0 + const { ref, start, end } = useScrollEdges("y"); + const mask = `linear-gradient(to bottom, transparent, #000 ${start && fadeTop ? fadePx : 0}px, #000 calc(100% - ${ + end ? fadePx : 0 }px), transparent)`; - return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } }; + return { ref, style: { maskImage: mask, WebkitMaskImage: mask } }; }