release: 0.71.0 (improvement/e5-s1-plex-platform)
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
+41
-18
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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=<secret>` 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:<id>`, 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:<id>`, 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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]}
|
||||
@@ -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:<id>`; 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,
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""E5 S5: the TMDB keyword filter. A keyword chip on the detail page sets ?keywords=<kw>, 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"] == []
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"]
|
||||
@@ -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 (
|
||||
<PopoverSurface popover={pop} ariaLabel="Choices">
|
||||
<PopoverItem role="menuitemradio" checked onSelect={() => {}}>
|
||||
Newest
|
||||
</PopoverItem>
|
||||
<PopoverItem role="menuitemradio" checked={false} onSelect={() => {}}>
|
||||
Oldest
|
||||
</PopoverItem>
|
||||
<PopoverItem role="menuitemcheckbox" checked onSelect={() => {}}>
|
||||
Show shorts
|
||||
</PopoverItem>
|
||||
</PopoverSurface>
|
||||
);
|
||||
}
|
||||
|
||||
describe("PopoverItem ARIA contract", () => {
|
||||
it("exposes aria-checked on menuitemradio and menuitemcheckbox items", () => {
|
||||
render(
|
||||
<FloatingTree>
|
||||
<Menu />
|
||||
</FloatingTree>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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 <PopoverItem role="option"> 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 <Combobox> 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 <Combobox>). */
|
||||
mode?: PopoverMode;
|
||||
placement?: Placement;
|
||||
/** Gap between trigger and panel, px. Default 4. */
|
||||
@@ -99,9 +115,14 @@ type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
|
||||
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 <FloatingTree> — 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 <Combobox>), 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 <Combobox> 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 <PopoverItem> never
|
||||
* takes DOM focus (roving tabindex off) and keeps the caret in the input on click. */
|
||||
virtual: boolean;
|
||||
}
|
||||
const PopoverListContext = createContext<PopoverListContextValue | null>(null);
|
||||
|
||||
@@ -302,6 +356,12 @@ export function PopoverItem({
|
||||
label?: string;
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "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<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...ariaProps}
|
||||
role={role}
|
||||
{...handlers}
|
||||
// These win over anything in `handlers` — the roving tabindex must not be a caller override,
|
||||
// and disabled/className/data-active are the platform's to set.
|
||||
tabIndex={focusActive ? 0 : -1}
|
||||
// A virtual option carries its own stable id (the aria-activedescendant target); it wins over any
|
||||
// id Floating UI's getItemProps would inject on activation.
|
||||
id={virtual ? generatedId : undefined}
|
||||
// These win over anything in `handlers` — the roving tabindex must not be a caller override, the
|
||||
// checked/selected truth must beat getItemProps' single-select guess, and disabled/className/
|
||||
// data-active are the platform's to set. A virtual (combobox) item never holds DOM focus, so its
|
||||
// tabindex is always -1; navigation is by aria-activedescendant.
|
||||
aria-checked={ariaChecked}
|
||||
aria-selected={ariaSelected}
|
||||
tabIndex={virtual ? -1 : focusActive ? 0 : -1}
|
||||
data-active={visualActive ? "true" : undefined}
|
||||
disabled={disabled}
|
||||
className={`outline-hidden ${
|
||||
@@ -409,6 +487,8 @@ function PopoverSurfaceInner({
|
||||
isPositioned,
|
||||
mode,
|
||||
isNav,
|
||||
isCombobox,
|
||||
roves,
|
||||
selectedIndex,
|
||||
activeIndex,
|
||||
highlightIndex,
|
||||
@@ -441,9 +521,10 @@ function PopoverSurfaceInner({
|
||||
if (ariaLabel) delete floatingProps["aria-labelledby"];
|
||||
|
||||
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
|
||||
// Everything but a passive hover card manages focus by default (trap into the surface, return to
|
||||
// the trigger on close).
|
||||
const managed = focusManaged ?? mode !== "hover";
|
||||
// A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is
|
||||
// passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither
|
||||
// is focus-managed.
|
||||
const managed = focusManaged ?? (mode !== "hover" && !isCombobox);
|
||||
|
||||
const surface = (
|
||||
<div
|
||||
@@ -477,11 +558,11 @@ function PopoverSurfaceInner({
|
||||
</div>
|
||||
);
|
||||
|
||||
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
|
||||
// <PopoverItem>s can self-register and rove.
|
||||
const positioned = isNav ? (
|
||||
// Menu/listbox AND combobox surfaces host the item registry (FloatingList) + active-state context so
|
||||
// nested <PopoverItem>s can self-register and rove/aria-activedescendant.
|
||||
const positioned = roves ? (
|
||||
<PopoverListContext.Provider
|
||||
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps }}
|
||||
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps, virtual: isCombobox }}
|
||||
>
|
||||
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
|
||||
{surface}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the shared horizontal rail. jsdom lays out nothing (scrollWidth/clientWidth are 0), so
|
||||
// the overflow-driven scroll buttons only appear once we feed the scroller fake metrics and fire a
|
||||
// scroll — which is exactly the affordance under test: a button shows ONLY on the clipped edge, and
|
||||
// clicking it pages the scroller. i18n is key-passthrough, so asserted names are the i18n keys.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import { Carousel } from "./Carousel";
|
||||
|
||||
beforeAll(() => {
|
||||
// useHScroll wires a ResizeObserver (watch-the-children); jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function setMetrics(
|
||||
el: HTMLElement,
|
||||
m: { scrollWidth: number; clientWidth: number; scrollLeft: number },
|
||||
) {
|
||||
Object.defineProperty(el, "scrollWidth", { configurable: true, value: m.scrollWidth });
|
||||
Object.defineProperty(el, "clientWidth", { configurable: true, value: m.clientWidth });
|
||||
Object.defineProperty(el, "scrollLeft", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: m.scrollLeft,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Carousel", () => {
|
||||
it("renders children and an optional header with a see-all action", () => {
|
||||
const onSeeAll = vi.fn();
|
||||
render(
|
||||
<Carousel title="Recently added" onSeeAll={onSeeAll}>
|
||||
<div>card-a</div>
|
||||
<div>card-b</div>
|
||||
</Carousel>,
|
||||
);
|
||||
expect(screen.getByText("card-a")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
|
||||
expect(onSeeAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("omits the header when no title is given and names the scroll region", () => {
|
||||
render(
|
||||
<Carousel ariaLabel="Cast">
|
||||
<div>x</div>
|
||||
</Carousel>,
|
||||
);
|
||||
expect(screen.queryByRole("heading")).toBeNull();
|
||||
expect(screen.getByRole("group", { name: "Cast" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows only the clipped edge's button and pages the scroller on click", () => {
|
||||
render(
|
||||
<Carousel ariaLabel="rail">
|
||||
<div>x</div>
|
||||
</Carousel>,
|
||||
);
|
||||
const scroller = screen.getByRole("group", { name: "rail" });
|
||||
const scrollBy = vi.fn();
|
||||
scroller.scrollBy = scrollBy as unknown as typeof scroller.scrollBy;
|
||||
|
||||
// At the left boundary with more content to the right → only the right button.
|
||||
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 0 });
|
||||
fireEvent.scroll(scroller);
|
||||
expect(screen.queryByRole("button", { name: "common.scrollLeft" })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.scrollRight" }));
|
||||
expect(scrollBy).toHaveBeenCalled();
|
||||
|
||||
// Scrolled into the middle → both edges clipped → both buttons.
|
||||
setMetrics(scroller, { scrollWidth: 1000, clientWidth: 300, scrollLeft: 350 });
|
||||
fireEvent.scroll(scroller);
|
||||
expect(screen.getByRole("button", { name: "common.scrollLeft" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "common.scrollRight" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useCallback, type ReactNode } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { useScrollEdges } from "../lib/useScrollEdges";
|
||||
|
||||
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
|
||||
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
|
||||
// Plex-Discover rows. Gives every consumer the same affordances for free: edge fade, hover/focus scroll
|
||||
// buttons that appear only on the side that actually has more content, a native scrollbar hidden behind
|
||||
// `.no-scrollbar`, and reduced-motion-aware paging. Purely presentational — the caller owns the cards.
|
||||
|
||||
// Prefer an instant jump when the viewer asked for reduced motion; smooth otherwise. Guarded for jsdom,
|
||||
// where matchMedia is often absent (the button paging is still exercised — it just uses "auto" there).
|
||||
function scrollBehavior(): ScrollBehavior {
|
||||
const reduce =
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
return reduce ? "auto" : "smooth";
|
||||
}
|
||||
|
||||
// Horizontal scroll-affordance: whether the scroller is clipped on the left / right, so the rail shows
|
||||
// a button + fade only where there's more to see. The X-axis face of the shared `useScrollEdges` core
|
||||
// (the same watch-the-scroller-and-its-children discipline as the vertical `useScrollFade`), plus the
|
||||
// element itself so the paging buttons can scroll it.
|
||||
function useHScroll(): {
|
||||
ref: (node: HTMLElement | null) => void;
|
||||
el: HTMLElement | null;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
} {
|
||||
const { ref, el, start, end } = useScrollEdges("x");
|
||||
return { ref, el, left: start, right: end };
|
||||
}
|
||||
|
||||
const FADE_PX = 28;
|
||||
|
||||
export function Carousel({
|
||||
title,
|
||||
onSeeAll,
|
||||
seeAllLabel,
|
||||
ariaLabel,
|
||||
gap = "gap-3",
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
/** Optional built-in header (rails). Omit for a bare scroller inside a card that owns its own heading. */
|
||||
title?: ReactNode;
|
||||
/** When set, a "see all" affordance appears in the header (needs `title`). */
|
||||
onSeeAll?: () => void;
|
||||
seeAllLabel?: string;
|
||||
/** Names the scroll region for AT; falls back to the string `title` when that's a plain string. */
|
||||
ariaLabel?: string;
|
||||
/** Tailwind gap class for the scroller (e.g. "gap-4"). */
|
||||
gap?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { ref, el, left, right } = useHScroll();
|
||||
|
||||
const page = useCallback(
|
||||
(dir: -1 | 1) => {
|
||||
if (!el) return;
|
||||
// ~one viewport-worth, but never a hair scroll on a narrow rail (or 0 in a zero-width jsdom box).
|
||||
const amount = Math.max(Math.round(el.clientWidth * 0.85), 200);
|
||||
el.scrollBy({ left: dir * amount, behavior: scrollBehavior() });
|
||||
},
|
||||
[el],
|
||||
);
|
||||
|
||||
// Fade only the clipped edge — no fade at a boundary, none when it all fits (matches useScrollFade).
|
||||
const mask = `linear-gradient(to right, transparent, #000 ${left ? FADE_PX : 0}px, #000 calc(100% - ${
|
||||
right ? FADE_PX : 0
|
||||
}px), transparent)`;
|
||||
|
||||
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
|
||||
|
||||
const arrowBtn =
|
||||
"absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
|
||||
|
||||
return (
|
||||
<div className={clsx("group/car relative", className)}>
|
||||
{title != null && (
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{onSeeAll && (
|
||||
<button
|
||||
onClick={onSeeAll}
|
||||
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
|
||||
>
|
||||
{seeAllLabel ?? t("common.seeAll")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{left && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => page(-1)}
|
||||
aria-label={t("common.scrollLeft")}
|
||||
className={clsx(arrowBtn, "left-1")}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={ref}
|
||||
className={clsx("no-scrollbar flex overflow-x-auto pb-1", gap)}
|
||||
style={{ maskImage: mask, WebkitMaskImage: mask }}
|
||||
role="group"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{right && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => page(1)}
|
||||
aria-label={t("common.scrollRight")}
|
||||
className={clsx(arrowBtn, "right-1")}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
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(<FloatingTree>{ui}</FloatingTree>);
|
||||
}
|
||||
|
||||
describe("Combobox", () => {
|
||||
it("renders selected values as removable chips and removes on ✕", () => {
|
||||
const onChange = vi.fn();
|
||||
wrap(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
|
||||
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(
|
||||
<Combobox
|
||||
label="Genre"
|
||||
options={OPTIONS}
|
||||
selected={["Drama", "Action"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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(<Combobox label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
|
||||
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(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={onChange} />);
|
||||
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(
|
||||
<Combobox
|
||||
label="Collection"
|
||||
multiple={false}
|
||||
clientFilter={false}
|
||||
options={serverOpts}
|
||||
selected={["stored-id"]}
|
||||
selectedLabels={{ "stored-id": "Stored Collection" }}
|
||||
query="har"
|
||||
onQueryChange={onQueryChange}
|
||||
onOpenChange={onOpenChange}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
// 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(
|
||||
<Combobox
|
||||
label="Collection"
|
||||
multiple={false}
|
||||
options={OPTIONS}
|
||||
selected={["Drama"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
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
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<HTMLInputElement>(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<Record<string, string>>({});
|
||||
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 (
|
||||
<div>
|
||||
{/* 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. */}
|
||||
<div
|
||||
ref={pop.refs.setPositionReference}
|
||||
onMouseDown={(e) => {
|
||||
// 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) => (
|
||||
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-xs">
|
||||
<span className="max-w-36 truncate">{labelFor(v)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
// Keep the box's focus-the-field handler from firing when removing a chip.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
onClick={() => onChange(selected.filter((x) => x !== v))}
|
||||
aria-label={t("common.remove", { defaultValue: "Remove" })}
|
||||
className="hover:opacity-70"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps({
|
||||
onChange: (e) => {
|
||||
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"
|
||||
/>
|
||||
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
</div>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={label}
|
||||
className="glass-menu w-full rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{/* The scroll box owns the height cap AND the scroll-fade mask (ref+style on the same element). */}
|
||||
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
|
||||
{loading && filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">
|
||||
{t("plex.loading", { defaultValue: "Loading…" })}
|
||||
</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-xs text-muted">
|
||||
{emptyText ?? t("common.noResults", { defaultValue: "No matches" })}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{visible.map((o) => {
|
||||
const on = selected.includes(o.value);
|
||||
return (
|
||||
<PopoverItem
|
||||
key={o.value}
|
||||
role="option"
|
||||
selected={on}
|
||||
disabled={o.disabled}
|
||||
onSelect={() => 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)
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border ${
|
||||
on ? "border-accent bg-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{o.label ?? o.value}</span>
|
||||
{o.count != null && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-muted">
|
||||
{o.count}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
{hiddenCount > 0 && (
|
||||
<p className="px-2 py-1.5 text-2xs text-muted">
|
||||
{t("plex.filter.moreRefine", {
|
||||
count: hiddenCount,
|
||||
defaultValue: "{{count}} more — refine with search",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Sub>({ 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<Sub>({ 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 = (
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("plex.view.label")}
|
||||
options={[
|
||||
{ value: "discover", label: t("plex.discover.tab") },
|
||||
{ value: "grid", label: t("plex.view.browse") },
|
||||
]}
|
||||
value={sub.view.kind === "discover" ? "discover" : "grid"}
|
||||
onChange={(v) => sub.open(v === "discover" ? { kind: "discover" } : { kind: "grid" })}
|
||||
/>
|
||||
);
|
||||
|
||||
if (sub.view.kind === "discover") {
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
<div className="mb-4 flex items-center">{viewToggle}</div>
|
||||
<PlexDiscover
|
||||
scope={scope}
|
||||
onOpenCard={onCard}
|
||||
onInfoCard={onInfo}
|
||||
onGenre={(g) => applyFilter({ genres: [g] })}
|
||||
onSeeAllRecent={() => sub.open({ kind: "grid" })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-[1600px] mx-auto">
|
||||
{/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put
|
||||
while only the poster grid scrolls. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
|
||||
{viewToggle}
|
||||
<p className="text-xs text-muted">
|
||||
{browseQ.isLoading
|
||||
? " "
|
||||
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
// Interpolate {{title}} so the because-you-watched heading is asserted with its anchor name.
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={onGenre}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={onSeeAllRecent}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("plex.discover.empty");
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{title}
|
||||
{action}
|
||||
</span>
|
||||
) : (
|
||||
title
|
||||
);
|
||||
return (
|
||||
<section className="mb-5">
|
||||
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
|
||||
{cards.map((c) => (
|
||||
<div key={c.id} className="w-[150px] shrink-0">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<PlexDiscoverData>(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 (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
);
|
||||
}
|
||||
if (q.isLoading || !q.data) {
|
||||
return <LoadingState label={t("plex.loading")} />;
|
||||
}
|
||||
|
||||
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 <p className="text-muted text-sm">{t("plex.discover.empty")}</p>;
|
||||
}
|
||||
|
||||
const railProps = { onOpenCard, onInfoCard, onToggleWatched: toggleWatched };
|
||||
|
||||
return (
|
||||
<div>
|
||||
{d.continue_watching.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.continueWatching")}
|
||||
ariaLabel={t("plex.discover.continueWatching")}
|
||||
cards={d.continue_watching}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{d.recently_added.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.recentlyAdded")}
|
||||
ariaLabel={t("plex.discover.recentlyAdded")}
|
||||
onSeeAll={onSeeAllRecent}
|
||||
cards={d.recently_added}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{surprise.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.surprise")}
|
||||
ariaLabel={t("plex.discover.surprise")}
|
||||
cards={surprise}
|
||||
action={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => surpriseQ.refetch()}
|
||||
aria-label={t("plex.discover.reshuffle")}
|
||||
title={t("plex.discover.reshuffle")}
|
||||
className="rounded-full p-1 text-muted transition hover:text-accent"
|
||||
>
|
||||
<RotateCcw className={`h-3.5 w-3.5 ${surpriseQ.isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{d.genres.length > 0 && (
|
||||
<section className="mb-5">
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
||||
{d.genres.map((g) => {
|
||||
const hue = genreHue(g.value);
|
||||
return (
|
||||
<button
|
||||
key={g.value}
|
||||
type="button"
|
||||
onClick={() => onGenre(g.value)}
|
||||
className="glass-hover relative flex h-20 items-end overflow-hidden rounded-xl p-3 text-left transition"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, hsl(${hue} 55% 32%), hsl(${(hue + 40) % 360} 60% 22%))`,
|
||||
}}
|
||||
>
|
||||
<span className="text-sm font-semibold text-white drop-shadow">{g.value}</span>
|
||||
<span className="absolute right-2 top-2 text-2xs text-white/70">{g.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bw && bw.items.length > 0 && (
|
||||
<CardRail
|
||||
title={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
|
||||
ariaLabel={t("plex.discover.becauseWatched", { title: bw.anchor.title })}
|
||||
cards={bw.items}
|
||||
{...railProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: Record<string, unknown>) =>
|
||||
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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe("PlexInfo enriched detail (page)", () => {
|
||||
it("renders the multi-source ratings row (IMDb / TMDb / audience)", () => {
|
||||
wrap(<PlexInfo detail={detail} variant="page" />);
|
||||
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(<PlexInfo detail={detail} variant="page" />);
|
||||
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(<PlexInfo detail={detail} variant="page" onFilter={onFilter} />);
|
||||
fireEvent.click(screen.getByText("heist"));
|
||||
expect(onFilter).toHaveBeenCalledWith({ keywords: ["heist"] });
|
||||
});
|
||||
|
||||
it("crew members open the person page on click", () => {
|
||||
const onOpenPerson = vi.fn();
|
||||
wrap(<PlexInfo detail={detail} variant="page" onOpenPerson={onOpenPerson} />);
|
||||
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(<PlexInfo detail={noImdb} variant="page" onFilter={onFilter} />);
|
||||
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(<PlexInfo detail={dupe} variant="page" />);
|
||||
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(<PlexInfo detail={detail} variant="overlay" />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<button
|
||||
onClick={() => onFilterRating(Math.floor(score))}
|
||||
className="inline-flex items-center gap-1 hover:opacity-80"
|
||||
title={t("plex.info.filterRating", { n: Math.floor(score) })}
|
||||
>
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Star className="h-3.5 w-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</span>
|
||||
);
|
||||
const has = (x?: number | null): x is number => x != null;
|
||||
if (!has(imdbRating) && !has(audience) && !has(tmdbRating)) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{has(imdbRating) && (
|
||||
<span className={chipCls}>
|
||||
<Score score={imdbRating} filterable={imdbRating === primary} />
|
||||
<span className="text-2xs font-normal opacity-70">IMDb</span>
|
||||
{imdbUrl && (
|
||||
<a
|
||||
href={imdbUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={t("plex.info.openImdb")}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 opacity-70 hover:opacity-100" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{has(tmdbRating) && (
|
||||
<span className={chipCls}>
|
||||
<Score score={tmdbRating} filterable={false} />
|
||||
<span className="text-2xs font-normal opacity-70">
|
||||
TMDb{has(tmdbVotes) ? ` · ${compact(tmdbVotes)}` : ""}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{has(audience) && imdbRating !== audience && (
|
||||
<span className={chipCls}>
|
||||
<Score score={audience} filterable={!has(imdbRating)} />
|
||||
<span className="text-2xs font-normal opacity-70">{t("plex.info.audience")}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<PlexFilters>) => 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: (
|
||||
<span className="flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||||
{studios.map((s, i) => (
|
||||
<span key={s}>
|
||||
<Filterable
|
||||
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
||||
className="hover:text-accent"
|
||||
>
|
||||
{s}
|
||||
</Filterable>
|
||||
{i < studios.length - 1 ? "," : ""}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
});
|
||||
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 (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||
{rows.map((r) => (
|
||||
<div key={r.key} className="contents">
|
||||
<dt className="text-muted">{r.label}</dt>
|
||||
<dd className="min-w-0 font-medium">{r.node}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (keywords.length === 0) return null;
|
||||
return (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((k) => (
|
||||
<Filterable
|
||||
key={k}
|
||||
className="rounded-full bg-surface px-2.5 py-1 text-xs text-muted hover:text-accent"
|
||||
title={onFilter ? t("plex.info.filterKeyword", { name: k }) : undefined}
|
||||
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
|
||||
>
|
||||
{k}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="glass-card rounded-2xl p-4 sm:p-5">
|
||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
||||
{/* 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. */}
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
||||
{crew.map((c, i) => (
|
||||
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
||||
{onOpenPerson ? (
|
||||
<button
|
||||
onClick={() => onOpenPerson(c.name)}
|
||||
title={t("plex.info.viewPerson", { name: c.name })}
|
||||
className="truncate text-left text-sm font-medium hover:text-accent"
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
) : (
|
||||
<div className="truncate text-sm font-medium">{c.name}</div>
|
||||
)}
|
||||
<div className="truncate text-2xs text-muted">{c.job}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<boolean>("plexInfoFacts", true);
|
||||
const [showKeywords, setShowKeywords] = usePref<boolean>("plexInfoKeywords", true);
|
||||
const [showCrew, setShowCrew] = usePref<boolean>("plexInfoCrew", true);
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(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 <main> 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 (
|
||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
|
||||
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
|
||||
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
|
||||
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
|
||||
<div className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}>
|
||||
{/* 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) => (
|
||||
<PrefToggle
|
||||
key={src}
|
||||
label={sourceLabel(src)}
|
||||
on={!hiddenSources.includes(src)}
|
||||
onClick={() => toggleSource(src)}
|
||||
/>
|
||||
))}
|
||||
extra={
|
||||
<>
|
||||
{hasFacts && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefFacts")}
|
||||
on={showFacts}
|
||||
onClick={() => setShowFacts(!showFacts)}
|
||||
/>
|
||||
)}
|
||||
{hasKeywords && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefKeywords")}
|
||||
on={showKeywords}
|
||||
onClick={() => setShowKeywords(!showKeywords)}
|
||||
/>
|
||||
)}
|
||||
{hasCrew && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefCrew")}
|
||||
on={showCrew}
|
||||
onClick={() => setShowCrew(!showCrew)}
|
||||
/>
|
||||
)}
|
||||
{presentSources.map((src) => (
|
||||
<PrefToggle
|
||||
key={src}
|
||||
label={sourceLabel(src)}
|
||||
on={!hiddenSources.includes(src)}
|
||||
onClick={() => toggleSource(src)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{overlay && onClose && (
|
||||
<button
|
||||
@@ -196,42 +479,18 @@ export default function PlexInfo({
|
||||
{detail.studio}
|
||||
</Filterable>
|
||||
)}
|
||||
{(() => {
|
||||
// IMDb score when we have one (with the IMDb link); otherwise the Plex audience
|
||||
// score (no IMDb link/branding, so a non-IMDb score is never mislabelled as IMDb).
|
||||
const badge = pickRatingBadge(detail.imdb_rating, detail.rating);
|
||||
if (!badge) return null;
|
||||
const { score, isImdb } = badge;
|
||||
return (
|
||||
// decorative rating gold — intentionally NOT a status token
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
|
||||
<button
|
||||
onClick={
|
||||
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
|
||||
}
|
||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
||||
title={
|
||||
onFilter
|
||||
? t("plex.info.filterRating", { n: Math.floor(score) })
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Star className="w-3.5 h-3.5 fill-current" />
|
||||
{score.toFixed(1)}
|
||||
</button>
|
||||
{isImdb && detail.imdb_url && (
|
||||
<a
|
||||
href={detail.imdb_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={t("plex.info.openImdb")}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
||||
<div className="mt-2">
|
||||
<RatingsRow
|
||||
imdbRating={detail.imdb_rating}
|
||||
imdbUrl={detail.imdb_url}
|
||||
audience={detail.rating}
|
||||
tmdbRating={detail.tmdb?.rating}
|
||||
tmdbVotes={detail.tmdb?.vote_count}
|
||||
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{detail.tagline && (
|
||||
@@ -342,13 +601,26 @@ export default function PlexInfo({
|
||||
</div>
|
||||
{/* 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) && (
|
||||
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />}
|
||||
{showKeywords && (
|
||||
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />
|
||||
)}
|
||||
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cast row (toggleable), circular photos. */}
|
||||
{cast.length > 0 && (
|
||||
<div className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}>
|
||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
{t("plex.info.cast")}
|
||||
</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
<Carousel ariaLabel={t("plex.info.cast")} gap="gap-3">
|
||||
{cast.map((c, i) => {
|
||||
const clickable = !!(onOpenPerson || onFilter);
|
||||
const inner = (
|
||||
@@ -403,10 +675,12 @@ export default function PlexInfo({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Carousel>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* (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({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
<Carousel ariaLabel={col.title} gap="gap-3">
|
||||
{col.items.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
@@ -471,7 +745,7 @@ export default function PlexInfo({
|
||||
{m.year ? <div className="text-2xs text-muted">{m.year}</div> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Carousel>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
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(
|
||||
<PlexPersonView
|
||||
name="Nobody"
|
||||
|
||||
@@ -3,16 +3,16 @@ import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { plexLang } from "../i18n";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
import { type PanelLayout } from "../lib/panelLayout";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import FilterRail from "./FilterRail";
|
||||
import { type PanelItem } from "./PanelGroups";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import { usePlexClearFilters } from "./usePlexActiveFilters";
|
||||
import { usePlex, usePlexActions } from "./PlexProvider";
|
||||
|
||||
@@ -68,7 +68,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
|
||||
const { scope, show, filters } = usePlex();
|
||||
const { setShow, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
|
||||
const collFade = useScrollFade();
|
||||
const playlistsQ = useQuery({ queryKey: qk.plexPlaylists(), queryFn: () => 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<PlexFilters>) => 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) => (
|
||||
<RemovableChip
|
||||
key={`s:${s}`}
|
||||
label={s}
|
||||
onRemove={() => patch({ studios: filters.studios.filter((x) => x !== s) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -221,44 +203,12 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
{
|
||||
id: "collection",
|
||||
title: t("plex.filter.collection"),
|
||||
body: filters.collection ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RemovableChip
|
||||
label={filters.collectionTitle || filters.collection}
|
||||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
value={collSearch}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div
|
||||
ref={collFade.ref}
|
||||
style={collFade.style}
|
||||
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{collections.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
|
||||
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate">{c.title}</span>
|
||||
<span className="text-xs text-muted">{c.child_count}</span>
|
||||
</button>
|
||||
))}
|
||||
{collections.length === 0 && (
|
||||
<span className="px-2 py-1 text-xs text-muted">
|
||||
{t("plex.filter.collectionEmpty")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
body: (
|
||||
<CollectionPicker
|
||||
value={filters.collection}
|
||||
title={filters.collectionTitle}
|
||||
onPick={(id, title) => patch({ collection: id, collectionTitle: title })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -284,27 +234,19 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
body: (
|
||||
<>
|
||||
{filters.genres.length > 1 && (
|
||||
<MatchToggle
|
||||
mode={filters.genreMode ?? "any"}
|
||||
onChange={(m) => patch({ genreMode: m })}
|
||||
/>
|
||||
<div className="mb-1.5">
|
||||
<MatchToggle
|
||||
mode={filters.genreMode ?? "any"}
|
||||
onChange={(m) => patch({ genreMode: m })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t("plex.filter.genre")}
|
||||
>
|
||||
{facets.genres.map((g) => (
|
||||
<button
|
||||
key={g.value}
|
||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||
aria-pressed={filters.genres.includes(g.value)}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{g.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Combobox
|
||||
label={t("plex.filter.genre")}
|
||||
options={facets.genres}
|
||||
selected={filters.genres}
|
||||
onChange={(next) => patch({ genres: next })}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -380,24 +322,28 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
|
||||
id: "contentrating",
|
||||
title: t("plex.filter.contentRating"),
|
||||
body: (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={t("plex.filter.contentRating")}
|
||||
>
|
||||
{facets.content_ratings.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
onClick={() =>
|
||||
patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })
|
||||
}
|
||||
aria-pressed={filters.contentRatings.includes(c.value)}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{c.value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Combobox
|
||||
label={t("plex.filter.contentRating")}
|
||||
options={facets.content_ratings}
|
||||
selected={filters.contentRatings}
|
||||
onChange={(next) => patch({ contentRatings: next })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(facets && facets.studios.length > 0
|
||||
? [
|
||||
{
|
||||
id: "studio",
|
||||
title: t("plex.filter.studio"),
|
||||
body: (
|
||||
<Combobox
|
||||
label={t("plex.filter.studio")}
|
||||
options={facets.studios}
|
||||
selected={filters.studios}
|
||||
onChange={(next) => 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 <Combobox> owns the field/keyboard/ARIA; this wrapper only owns the query→fetch (gated on
|
||||
// `open`) and maps collections to options. Picking sets the filter (id + title for the chip); clearing
|
||||
// the chip drops it.
|
||||
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 (
|
||||
<Combobox
|
||||
label={t("plex.filter.collection")}
|
||||
multiple={false}
|
||||
clientFilter={false} // the server already narrows to `search`
|
||||
options={options}
|
||||
selected={value ? [value] : []}
|
||||
selectedLabels={value && title ? { [value]: title } : undefined}
|
||||
query={search}
|
||||
onQueryChange={setSearch}
|
||||
onOpenChange={setOpen}
|
||||
loading={q.isFetching}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
emptyText={t("plex.filter.collectionEmpty")}
|
||||
onChange={(next) => {
|
||||
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 (
|
||||
<span className="chip px-2 py-1 text-xs">
|
||||
|
||||
@@ -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(
|
||||
<PlexPosterCard
|
||||
card={{ ...movie(), year: 2023, rating: 7.5, summary: "A quiet synopsis." }}
|
||||
onOpen={vi.fn()}
|
||||
onInfo={vi.fn()}
|
||||
onToggleWatched={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<PlexPosterCard card={movie()} onOpen={vi.fn()} onInfo={vi.fn()} onToggleWatched={vi.fn()} />,
|
||||
);
|
||||
expect(screen.queryByText(/synopsis/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SeasonCard a11y", () => {
|
||||
|
||||
@@ -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). */}
|
||||
<div className="absolute inset-0 bg-black/45 opacity-0 group-hover:opacity-100 transition grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-1 text-white">
|
||||
<Play className="w-10 h-10" fill="currentColor" />
|
||||
<span className="text-xs font-medium">
|
||||
{card.type === "show"
|
||||
? t("plex.openShow")
|
||||
: inProgress
|
||||
? t("plex.resume")
|
||||
: t("plex.play")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Quick watched toggle (movies/episodes), on hover. */}
|
||||
{isPlayable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched(card);
|
||||
}}
|
||||
title={watchedLabel}
|
||||
aria-label={watchedLabel}
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
>
|
||||
<WatchedIcon watched={card.status === "watched"} />
|
||||
</button>
|
||||
{/* Playable-cost tint (always shown — a quick "will it direct-play" cue). */}
|
||||
{card.playable && (
|
||||
<span
|
||||
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ring-2 ring-black/40 ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||
title={t(`plex.playable.${card.playable}`)}
|
||||
/>
|
||||
)}
|
||||
{/* Media info page (movies/episodes). */}
|
||||
{isPlayable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInfo();
|
||||
}}
|
||||
title={t("plex.info.title")}
|
||||
aria-label={t("plex.info.title")}
|
||||
className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 reveal hover:bg-black/80 transition"
|
||||
>
|
||||
<Info className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
)}
|
||||
{/* 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" && (
|
||||
<span
|
||||
className={`pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm ${swapBadgeReveal(
|
||||
@@ -133,34 +100,99 @@ export function PlexPosterCard({
|
||||
{t("plex.watched")}
|
||||
</span>
|
||||
)}
|
||||
{card.type === "show" && card.season_count != null && (
|
||||
<span className="absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm">
|
||||
{t("plex.seasons", { count: card.season_count })}
|
||||
</span>
|
||||
)}
|
||||
{/* Aggregate in-progress badge for a partially-watched show. */}
|
||||
{card.type === "show" && card.status === "in_progress" && (
|
||||
<span className="pointer-events-none absolute top-1.5 right-1.5 text-3xs bg-black/75 text-accent px-1.5 py-0.5 rounded-sm group-hover:opacity-0">
|
||||
{t("plex.inProgress")}
|
||||
</span>
|
||||
)}
|
||||
{card.playable && (
|
||||
<span
|
||||
className={`absolute top-1.5 left-1.5 w-2 h-2 rounded-full ${PLAYABLE_TINT[card.playable] ?? "bg-gray-400"}`}
|
||||
title={t(`plex.playable.${card.playable}`)}
|
||||
/>
|
||||
{/* Rest-state bottom badges — hidden on hover, where the scrim takes over the bottom. */}
|
||||
{card.type === "show" && card.season_count != null && (
|
||||
<span className="pointer-events-none absolute bottom-1.5 left-1.5 text-3xs bg-black/70 text-white px-1.5 py-0.5 rounded-sm transition-opacity group-hover:opacity-0">
|
||||
{t("plex.seasons", { count: card.season_count })}
|
||||
</span>
|
||||
)}
|
||||
{/* Type tag — tell a movie card from a show card at a glance (unified feed). */}
|
||||
<span
|
||||
className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90"
|
||||
className="pointer-events-none absolute bottom-1.5 right-1.5 rounded-sm bg-black/65 p-0.5 text-white/90 transition-opacity group-hover:opacity-0"
|
||||
title={t(
|
||||
card.type === "show" ? "plex.filter.scopeOpt.show" : "plex.filter.scopeOpt.movie",
|
||||
)}
|
||||
>
|
||||
{card.type === "show" ? <Tv2 className="w-3 h-3" /> : <Film className="w-3 h-3" />}
|
||||
</span>
|
||||
|
||||
{/* 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). */}
|
||||
<div className="absolute inset-0 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<div className="absolute inset-0 bg-black/25" />
|
||||
<div className="absolute inset-x-0 top-[30%] flex justify-center">
|
||||
<div
|
||||
className="grid h-12 w-12 scale-90 place-items-center rounded-full text-white shadow-xl transition-transform duration-200 group-hover:scale-100"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(circle at 38% 32%, color-mix(in srgb, var(--accent) 72%, white), var(--accent))",
|
||||
boxShadow:
|
||||
"0 10px 22px -6px color-mix(in srgb, var(--accent) 60%, transparent), 0 0 0 4px color-mix(in srgb, var(--accent) 22%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Play className="w-5 h-5 translate-x-px fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
{(card.summary || card.rating != null) && (
|
||||
<div className="absolute inset-x-0 bottom-0 translate-y-1.5 bg-gradient-to-t from-black/95 via-black/85 to-transparent p-2.5 transition-transform duration-200 group-hover:translate-y-0">
|
||||
<div className="line-clamp-1 text-xs font-semibold leading-snug text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-2xs text-white/75">
|
||||
{(card.year || runtime) && (
|
||||
<span>{[card.year, runtime].filter(Boolean).join(" · ")}</span>
|
||||
)}
|
||||
{card.rating != null && (
|
||||
<span className="inline-flex items-center gap-0.5 text-amber-300">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{card.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{card.summary && (
|
||||
<p className="mt-1 line-clamp-3 text-2xs leading-snug text-white/85">
|
||||
{card.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick actions (playable cards): Info + watched toggle — revealed on hover, always on touch. */}
|
||||
{isPlayable && (
|
||||
<div className="reveal absolute right-1.5 top-1.5 z-10 flex gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInfo();
|
||||
}}
|
||||
title={t("plex.info.title")}
|
||||
aria-label={t("plex.info.title")}
|
||||
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
|
||||
>
|
||||
<Info className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleWatched(card);
|
||||
}}
|
||||
title={watchedLabel}
|
||||
aria-label={watchedLabel}
|
||||
className="rounded-full bg-black/60 p-1 transition hover:bg-black/80"
|
||||
>
|
||||
<WatchedIcon watched={card.status === "watched"} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-1 bg-black/40">
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 h-1 bg-black/40">
|
||||
<div className="h-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
@@ -301,7 +333,7 @@ export function CastStrip({
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.info.cast")}</h2>
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
<Carousel gap="gap-4" ariaLabel={t("plex.info.cast")}>
|
||||
{cast.map((c, i) => {
|
||||
const inner = (
|
||||
<>
|
||||
@@ -316,12 +348,19 @@ export function CastStrip({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
|
||||
className={`mt-1.5 truncate text-xs font-medium leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
|
||||
title={c.name}
|
||||
>
|
||||
{c.name}
|
||||
</div>
|
||||
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
|
||||
{c.role && (
|
||||
<div className="truncate text-2xs text-muted" title={c.role}>
|
||||
{c.role}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return clickable ? (
|
||||
@@ -345,7 +384,7 @@ export function CastStrip({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -364,7 +403,7 @@ export function RelatedStrip({
|
||||
style={{ background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||
>
|
||||
<h2 className="text-sm font-semibold mb-3">{t("plex.series.related")}</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
<Carousel gap="gap-3" ariaLabel={t("plex.series.related")}>
|
||||
{related.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
@@ -389,7 +428,7 @@ export function RelatedStrip({
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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=<kind>:<args>", () => {
|
||||
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" },
|
||||
|
||||
@@ -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=<kind>:<args> 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<Sub> = {
|
||||
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<Sub> = {
|
||||
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<Sub> = {
|
||||
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":
|
||||
|
||||
@@ -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 ? (
|
||||
<section className="mb-8">
|
||||
<section className="mb-5">
|
||||
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{cards.map((c) => (
|
||||
@@ -167,33 +181,78 @@ export function PlexPersonView({
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : (
|
||||
<div className="p-4">
|
||||
{/* Hero: headshot + name + a "filter the library by this person" action. */}
|
||||
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6 flex items-center gap-4">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||
{d.photo ? (
|
||||
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center opacity-40">
|
||||
<User className="h-8 w-8" />
|
||||
{/* Hero: headshot + name + TMDB bio/birth facts (E5 S6) + a "filter the library" action. */}
|
||||
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
|
||||
{d.photo ? (
|
||||
<img src={d.photo} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center opacity-40">
|
||||
<User className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="text-xl font-semibold">{d.name}</h1>
|
||||
{d.known_for_department && (
|
||||
<p className="text-xs font-medium text-accent">{d.known_for_department}</p>
|
||||
)}
|
||||
<p className="mt-1 mb-2 text-xs text-muted">
|
||||
{t("plex.person.credits", { movies: d.movies.length, shows: d.shows.length })}
|
||||
</p>
|
||||
<div className="mb-2 space-y-0.5 text-xs text-muted">
|
||||
{(d.birthday || d.place_of_birth) && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.born")}:</span>{" "}
|
||||
{[d.birthday, d.place_of_birth].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
{d.deathday && (
|
||||
<p>
|
||||
<span className="text-fg/70">{t("plex.person.died")}:</span> {d.deathday}
|
||||
</p>
|
||||
)}
|
||||
{d.also_known_as.length > 0 && (
|
||||
<p className="truncate">
|
||||
<span className="text-fg/70">{t("plex.person.aka")}:</span>{" "}
|
||||
{d.also_known_as.slice(0, 4).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onFilter({ actors: [d.name] })}
|
||||
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
{t("plex.person.filterBy", { name: d.name })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold">{d.name}</h1>
|
||||
<p className="mb-2 text-xs text-muted">
|
||||
{t("plex.person.credits", {
|
||||
movies: d.movies.length,
|
||||
shows: d.shows.length,
|
||||
})}
|
||||
{d.biography && (
|
||||
<p className="mt-4 whitespace-pre-line text-sm leading-relaxed text-muted">
|
||||
{d.biography}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onFilter({ actors: [d.name] })}
|
||||
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
{t("plex.person.filterBy", { name: d.name })}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
|
||||
{d.known_for.length > 0 && (
|
||||
<section className="mb-5">
|
||||
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
|
||||
{d.known_for.map((c) => (
|
||||
<div key={c.id} className="w-[150px] shrink-0">
|
||||
<PlexPosterCard
|
||||
card={c}
|
||||
onOpen={() => onOpenCard(c)}
|
||||
onInfo={() => onInfoCard(c)}
|
||||
onToggleWatched={toggle}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{empty ? (
|
||||
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
|
||||
) : (
|
||||
@@ -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({
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
crossfade={crossfade}
|
||||
onToggleCrossfade={toggleCrossfade}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={show.cast.length > 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({
|
||||
<DetailCustomizeMenu
|
||||
artBg={artBg}
|
||||
onToggleArtBg={toggleArtBg}
|
||||
crossfade={crossfade}
|
||||
onToggleCrossfade={toggleCrossfade}
|
||||
showCast={showCast}
|
||||
onToggleCast={toggleCast}
|
||||
hasCast={false}
|
||||
|
||||
@@ -95,6 +95,13 @@ export function usePlexActiveFilters(): ActiveFilter[] {
|
||||
remove: () => 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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 újraszkennel — 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": {
|
||||
|
||||
@@ -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<PlexDiscover> => {
|
||||
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<PlexShowDetail> =>
|
||||
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.
|
||||
|
||||
@@ -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(<Harness arts={["/a.jpg", "/b.jpg", "/c.jpg"]} />);
|
||||
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(<Harness arts={["/a.jpg", "/b.jpg"]} />);
|
||||
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(<Harness arts={["/only.jpg", null, undefined]} />);
|
||||
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(<Harness arts={["/a.jpg", "/b.jpg"]} animate={false} />);
|
||||
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(<Harness arts={["/a.jpg", "/b.jpg"]} enabled={false} />);
|
||||
expect(root().style.backgroundImage).toBe("");
|
||||
expect(overlays().length).toBe(0);
|
||||
rerender(<Harness arts={["/a.jpg", "/b.jpg"]} enabled={true} />);
|
||||
expect(overlays().length).toBe(1);
|
||||
unmount();
|
||||
expect(overlays().length).toBe(0); // cleaned up
|
||||
expect(root().style.isolation).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -24,18 +24,30 @@ export function useDetailPrefs() {
|
||||
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
||||
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
||||
const [showRelated, setShowRelated] = usePref<boolean>("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<boolean>("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 <main> 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<typeof setTimeout> | 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)" }}
|
||||
>
|
||||
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||
{artBg && onToggleCrossfade && (
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefCrossfade")}
|
||||
on={crossfade ?? true}
|
||||
onClick={onToggleCrossfade}
|
||||
/>
|
||||
)}
|
||||
{hasCast && (
|
||||
<PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLElement | null>(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 };
|
||||
}
|
||||
@@ -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<HTMLElement | null>(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 } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user