diff --git a/VERSION b/VERSION index a9ca1dcc..367d4880 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.65.2 \ No newline at end of file +0.66.0 \ No newline at end of file diff --git a/backend/alembic/versions/0063_plex_library_overlay.py b/backend/alembic/versions/0063_plex_library_overlay.py new file mode 100644 index 00000000..8fdca10f --- /dev/null +++ b/backend/alembic/versions/0063_plex_library_overlay.py @@ -0,0 +1,39 @@ +"""plex library HU-overlay columns (E5 S5a) + +Adds `metadata_lang` (default 'en') and a self-referential `overlay_of` to plex_libraries. An +OVERLAY library is the Hungarian-metadata twin of another section: it is never browsed on its own — +its titles' HU fields are written onto the matched EN rows (paired by `guid`, see 0064). Cheap: +metadata-only, no table rewrite. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0063_plex_library_overlay" +down_revision: Union[str, None] = "0062_drop_redundant_idx" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "plex_libraries", + sa.Column("metadata_lang", sa.String(8), nullable=False, server_default="en"), + ) + op.add_column( + "plex_libraries", + sa.Column( + "overlay_of", + sa.Integer(), + sa.ForeignKey("plex_libraries.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index("ix_plex_libraries_overlay_of", "plex_libraries", ["overlay_of"]) + + +def downgrade() -> None: + op.drop_index("ix_plex_libraries_overlay_of", table_name="plex_libraries") + op.drop_column("plex_libraries", "overlay_of") + op.drop_column("plex_libraries", "metadata_lang") diff --git a/backend/alembic/versions/0064_plex_hu_overlay_cols.py b/backend/alembic/versions/0064_plex_hu_overlay_cols.py new file mode 100644 index 00000000..2d433531 --- /dev/null +++ b/backend/alembic/versions/0064_plex_hu_overlay_cols.py @@ -0,0 +1,58 @@ +"""plex HU-overlay item/show columns + guid (E5 S5a) + +On plex_items and plex_shows: `guid` (canonical external id, the EN<->HU pairing key), the Hungarian +twin fields `title_hu`/`summary_hu`/`genres_hu`, and a STORED generated `search_vector_hu` mirroring +the EN vector (title_hu A / people_text B / left(summary_hu,1000) C) + a GIN index. people_text is +language-neutral, reused for both vectors. + +Run BEFORE the first HU overlay sync: while the *_hu columns are all NULL the generated-column build +is trivial. ADD only (no drop/rebuild of the EN search_vector). NOTE: adding a STORED generated +column + its GIN takes an ACCESS EXCLUSIVE lock and rewrites the table — brief, but flag it on prod. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0064_plex_hu_overlay_cols" +down_revision: Union[str, None] = "0063_plex_library_overlay" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLES = ("plex_items", "plex_shows") + +# Mirror of the EN search_vector (models.py) but over the HU twin columns; people_text is shared. +_VECTOR_HU = ( + "setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || " + "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " + "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')" +) + + +def upgrade() -> None: + for t in _TABLES: + op.add_column(t, sa.Column("guid", sa.String(64), nullable=True)) + op.add_column(t, sa.Column("title_hu", sa.String(512), nullable=True)) + op.add_column(t, sa.Column("summary_hu", sa.Text(), nullable=True)) + op.add_column(t, sa.Column("genres_hu", postgresql.JSONB(), nullable=True)) + op.create_index(f"ix_{t}_guid", t, ["guid"]) + op.create_index(f"ix_{t}_genres_hu_gin", t, ["genres_hu"], postgresql_using="gin") + # Generated column can't be expressed via add_column — raw DDL, matching the EN idiom (0044). + op.execute( + f"ALTER TABLE {t} ADD COLUMN search_vector_hu tsvector " + f"GENERATED ALWAYS AS ({_VECTOR_HU}) STORED" + ) + op.execute(f"CREATE INDEX ix_{t}_search_vector_hu ON {t} USING gin (search_vector_hu)") + + +def downgrade() -> None: + for t in _TABLES: + op.execute(f"DROP INDEX IF EXISTS ix_{t}_search_vector_hu") + op.execute(f"ALTER TABLE {t} DROP COLUMN IF EXISTS search_vector_hu") + op.drop_index(f"ix_{t}_genres_hu_gin", table_name=t) + op.drop_index(f"ix_{t}_guid", table_name=t) + op.drop_column(t, "genres_hu") + op.drop_column(t, "summary_hu") + op.drop_column(t, "title_hu") + op.drop_column(t, "guid") diff --git a/backend/alembic/versions/0065_plex_detail_fields.py b/backend/alembic/versions/0065_plex_detail_fields.py new file mode 100644 index 00000000..c5b62be6 --- /dev/null +++ b/backend/alembic/versions/0065_plex_detail_fields.py @@ -0,0 +1,45 @@ +"""plex rich-detail item/show columns (E5 S5b) + +On plex_items and plex_shows: the fields that were previously read LIVE from plex.metadata() on every +info-page view — `imdb_rating` (the IMDb-specific score, distinct from `rating` = audienceRating), +`imdb_id` (tt…), `tagline`, and `full_cast` (ordered [{name, role, thumb}]). Persisting them lets the +detail endpoints serve from the DB row instead of a live Plex call (outage-resilient, and the +precondition for S6's pure-DB language-aware serving). + +These are filled by the same rich backfill that already fills the full cast (`_enrich_people_batch`), +so the data step below resets `people_enriched_at` to NULL on movie/show rows: the backfill re-fetches +each once (60/run, self-throttled) and stamps the new columns. ADD-only, all nullable → no table +rewrite, no generated column, no ACCESS EXCLUSIVE lock (unlike 0064). +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "0065_plex_detail_fields" +down_revision: Union[str, None] = "0064_plex_hu_overlay_cols" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLES = ("plex_items", "plex_shows") + + +def upgrade() -> None: + for t in _TABLES: + op.add_column(t, sa.Column("imdb_rating", sa.Float(), nullable=True)) + op.add_column(t, sa.Column("imdb_id", sa.String(16), nullable=True)) + op.add_column(t, sa.Column("tagline", sa.Text(), nullable=True)) + op.add_column(t, sa.Column("full_cast", postgresql.JSONB(), nullable=True)) + # Force a one-time re-enrich so already-enriched rows gain the new columns (episodes are never + # enriched, so their NULL people_enriched_at is unaffected; they keep serving detail live). + op.execute("UPDATE plex_items SET people_enriched_at = NULL WHERE kind = 'movie'") + op.execute("UPDATE plex_shows SET people_enriched_at = NULL") + + +def downgrade() -> None: + for t in _TABLES: + op.drop_column(t, "full_cast") + op.drop_column(t, "tagline") + op.drop_column(t, "imdb_id") + op.drop_column(t, "imdb_rating") diff --git a/backend/app/config.py b/backend/app/config.py index e0f2707f..6e7abb91 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -221,6 +221,10 @@ class Settings(BaseSettings): plex_path_map: str = "" # Which Plex library section keys are exposed (comma-separated). Empty = all enabled sections. plex_libraries: str = "" + # HU-overlay mapping (E5 S5): comma-separated `huSectionKey:enSectionKey` pairs. Each named HU + # section is synced as a non-browsable overlay whose Hungarian metadata is written onto the EN + # twin section's rows (matched by guid). Empty = no overlays. E.g. "3:4,5:1". + plex_overlay_map: str = "" # Stable client identifier Siftlode presents to Plex (X-Plex-Client-Identifier). plex_client_id: str = "siftlode-server" # How often the catalog sync job mirrors Plex metadata, in minutes. diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py new file mode 100644 index 00000000..93e82ac2 --- /dev/null +++ b/backend/app/feedquery.py @@ -0,0 +1,426 @@ +"""Feed read-model: the pure query-building and row-serialization layer behind the feed. + +Extracted from `routes/feed.py` so the read-model has one home instead of living inside the +router module — the feed route handlers and the playlist-detail query share these helpers (the +projection, the filter builder, and keyset paging). Nothing here defines a route or reaches for +request state; it's all `(db, user, params) -> query`, and a malformed cursor raises a plain +`ValueError` for the route handler to translate (the tsquery sanitizer lives in `app.utils`). +""" + +import base64 +import binascii +import json +from datetime import date, datetime, timedelta, timezone + +from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select +from sqlalchemy.orm import Session, aliased + +from app.models import ( + LIVE_OR_UPCOMING, + BlockedChannel, + Channel, + ChannelTag, + ExploredChannel, + Playlist, + PlaylistItem, + SearchFind, + Subscription, + Tag, + User, + Video, + VideoState, +) +from app.utils import iso, to_tsquery_str + + +def _channel_url(channel_id: str, handle: str | None) -> str: + if handle and handle.startswith("@"): + return f"https://www.youtube.com/{handle}" + return f"https://www.youtube.com/channel/{channel_id}" + + +def _saved_expr(user: User): + """A correlated EXISTS labelled `saved`: is this video in the user's Watch later list? + (Watch later is the built-in playlist that replaced the old per-video 'saved' status.)""" + return ( + select(1) + .select_from(PlaylistItem) + .join(Playlist, Playlist.id == PlaylistItem.playlist_id) + .where( + Playlist.user_id == user.id, + Playlist.kind == "watch_later", + PlaylistItem.video_id == Video.id, + ) + .exists() + .label("saved") + ) + + +def feed_columns(user: User, state) -> list: + """The shared feed-row projection (joined Channel fields + per-user watch state + + `saved`), consumed by both the feed query and the playlist-detail query so a new column + is added in exactly one place. `state` is the caller's aliased VideoState (outer-joined).""" + return [ + Video.id, + Video.title, + Video.channel_id, + Channel.title.label("channel_title"), + Channel.thumbnail_url.label("channel_thumbnail"), + Channel.handle.label("channel_handle"), + Video.published_at, + Video.thumbnail_url, + Video.duration_seconds, + Video.view_count, + Video.is_short, + Video.live_status, + func.coalesce(state.status, "new").label("status"), + func.coalesce(state.position_seconds, 0).label("position_seconds"), + _saved_expr(user), + ] + + +def serialize(row) -> dict: + return { + "id": row.id, + "title": row.title, + "channel_id": row.channel_id, + "channel_title": row.channel_title, + "channel_thumbnail": row.channel_thumbnail, + "channel_url": _channel_url(row.channel_id, row.channel_handle), + "published_at": iso(row.published_at), + "thumbnail_url": row.thumbnail_url, + "duration_seconds": row.duration_seconds, + "view_count": row.view_count, + "is_short": row.is_short, + "live_status": row.live_status, + "status": row.status or "new", + "position_seconds": row.position_seconds or 0, + "saved": bool(getattr(row, "saved", False)), + "watch_url": f"https://www.youtube.com/watch?v={row.id}", + } + + +def filtered_query( + db: Session, + user: User, + *, + tags: list[int], + tag_mode: str, + channel_ids: list[str], + q: str | None, + min_duration: int | None, + max_duration: int | None, + max_age_days: int | None, + published_after: date | None, + published_before: date | None, + show_normal: bool, + include_shorts: bool, + include_live: bool, + show: str, + scope: str = "my", + library_source: str = "organic", + exclude_tag_category: str | None = None, +) -> tuple[Select, object]: + """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. + Returns the column-bearing select plus the priority/relevance rank expression for sorting. + + `scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions. + `scope="all"` shows every video in the shared catalog (any user's ingested channels); + the subscription is then only LEFT-joined so per-channel priority still resolves for + channels the user happens to be subscribed to. Per-user watch state stays private in + either mode via the VideoState outer join.""" + state = aliased(VideoState) + status_expr = func.coalesce(state.status, "new") + position_expr = func.coalesce(state.position_seconds, 0) + + query = select(*feed_columns(user, state)).join( + Channel, Channel.id == Video.channel_id + ) + + if scope == "all": + # Whole shared catalog; subscription is optional (only for priority sort). + query = query.outerjoin( + Subscription, + and_( + Subscription.channel_id == Video.channel_id, + Subscription.user_id == user.id, + ), + ) + else: + # "Mine": the user's own videos = their non-hidden subscriptions OR videos they + # surfaced via their own live YouTube search. Both are LEFT-joined so the Source + # filter below can include either or both, and the subscription row stays available + # for the priority sort. + query = query.outerjoin( + Subscription, + and_( + Subscription.channel_id == Video.channel_id, + Subscription.user_id == user.id, + Subscription.hidden.is_(False), + ), + ).outerjoin( + SearchFind, + and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id), + ) + + query = query.outerjoin( + state, and_(state.video_id == Video.id, state.user_id == user.id) + ) + + # Hide videos the maintenance job has flagged as currently unplayable (deleted/private/ + # abandoned). They're either deleted after a grace period or unhidden if they recover; + # either way they shouldn't show in any feed view meanwhile. + query = query.where(Video.unavailable_since.is_(None)) + + # Explored videos are PER-EXPLORER private: a via_explore video (ingested when someone + # opened an un-subscribed channel's page) is visible only to users who explored or + # subscribe to its channel, so one user's curiosity never leaks into everyone else's + # catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by + # asking for scope=all + channel_ids (the channel is in this accessible set). + accessible_explore = ( + select(Subscription.channel_id).where(Subscription.user_id == user.id) + ).union( + select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id) + ) + query = query.where( + or_( + Video.via_explore.is_(False), + Video.channel_id.in_(accessible_explore), + ) + ) + + # Per-user channel blocklist: never show this user videos from a channel they blocked + # (applies to feed, Library, explore, and the channel page alike). + query = query.where( + Video.channel_id.notin_( + select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id) + ) + ) + + # Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag + # (search-discovered by anyone). In "my" scope it's the user's OWN provenance: + # "organic" = your subscriptions, "search" = videos you found via your own search, + # "all" = both. Default "organic" keeps the Mine feed = subscriptions only. + if scope == "all": + if library_source == "organic": + query = query.where(Video.via_search.is_(False)) + elif library_source == "search": + query = query.where(Video.via_search.is_(True)) + # "all" → both organic and search-discovered videos + else: + subscribed = Subscription.channel_id.isnot(None) + searched = SearchFind.video_id.isnot(None) + if library_source == "search": + query = query.where(searched) + elif library_source == "all": + query = query.where(or_(subscribed, searched)) + else: # "organic" (default): subscriptions only + query = query.where(subscribed) + + if channel_ids: + # OR across the picked channels; every other filter still applies on top. + query = query.where(Video.channel_id.in_(channel_ids)) + if min_duration is not None: + query = query.where(Video.duration_seconds >= min_duration) + if max_duration is not None: + query = query.where(Video.duration_seconds <= max_duration) + if max_age_days is not None: + cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400 + query = query.where( + Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc) + ) + if published_after is not None: + start = datetime.combine( + published_after, datetime.min.time(), tzinfo=timezone.utc + ) + query = query.where(Video.published_at >= start) + if published_before is not None: + end = datetime.combine( + published_before, datetime.min.time(), tzinfo=timezone.utc + ) + timedelta(days=1) + query = query.where(Video.published_at < end) + # Full-text relevance search over the weighted search document (title > creator keywords + + # the queries that surfaced the video > description; see Video.search_vector). YouTube-like + # "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed, + # accent-insensitive (unaccent_simple). The channel name still matches as a substring so + # channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort. + rank_expr = None + if q: + ts_str = to_tsquery_str(q) + if ts_str: + tsq = func.to_tsquery("public.unaccent_simple", ts_str) + query = query.where( + or_( + Video.search_vector.op("@@")(tsq), + func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")), + ) + ) + # Scale ts_rank (a float4) to an integer score so the keyset cursor compares it + # EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and + # breaks paging (the same page repeats). 1e6 granularity is ample; ties break on + # published_at then id. + rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger) + else: + # No usable tokens (e.g. only punctuation): fall back to a plain substring match. + like = func.unaccent(f"%{q}%") + query = query.where( + or_( + func.unaccent(Video.title).ilike(like), + func.unaccent(Channel.title).ilike(like), + ) + ) + + if tags: + # AND across tag categories (e.g. language AND topic narrows), OR within a + # category; the any/all toggle controls multiple topic tags. + cat_rows = db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tags))).all() + by_category: dict[str, list[int]] = {} + for tag_id, category in cat_rows: + by_category.setdefault(category, []).append(tag_id) + # Facet counting drops the category being counted so its own chips don't zero each + # other out (standard drill-down faceting: a category's count ignores its own + # selections but still honours the other categories' filters). + if exclude_tag_category: + by_category.pop(exclude_tag_category, None) + visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) + for category, ids in by_category.items(): + if category == "topic" and tag_mode == "and" and len(ids) > 1: + sub = ( + select(ChannelTag.channel_id) + .where(ChannelTag.tag_id.in_(ids), visible) + .group_by(ChannelTag.channel_id) + .having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids))) + ) + else: + sub = select(ChannelTag.channel_id).where( + ChannelTag.tag_id.in_(ids), visible + ) + query = query.where(Video.channel_id.in_(sub)) + + # Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. + # Explicit Watched/Saved/Hidden views show every type so nothing goes missing. + explicit_view = show in ("watched", "hidden") + if not explicit_view: + type_clauses = [] + if show_normal: + type_clauses.append( + and_(Video.is_short.is_(False), Video.live_status.notin_(LIVE_OR_UPCOMING)) + ) + if include_shorts: + type_clauses.append(Video.is_short.is_(True)) + if include_live: + type_clauses.append(Video.live_status.in_(LIVE_OR_UPCOMING)) + query = query.where(or_(*type_clauses) if type_clauses else false()) + + if show == "unwatched": + query = query.where(status_expr.notin_(("watched", "hidden"))) + elif show == "in_progress": + # Started but not finished: a stored resume position, not yet watched/hidden. + query = query.where( + and_(position_expr > 0, status_expr.notin_(("watched", "hidden"))) + ) + elif show == "watched": + query = query.where(status_expr == "watched") + elif show == "hidden": + query = query.where(status_expr == "hidden") + else: # all + query = query.where(status_expr != "hidden") + + return query, rank_expr + + +# --- Keyset (cursor) pagination --------------------------------------------- +# +# Each sort is described as an ordered list of key columns (expression + direction +# + whether it can be NULL + a JSON round-trip kind), always with Video.id as the +# final, unique tiebreaker. This drives BOTH the ORDER BY and the keyset WHERE so +# they can never drift apart — the prerequisite for correct cursor paging. Keyset +# replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows, +# and the page boundary is stable even as the scheduler ingests new videos mid-scroll +# (OFFSET would shift and duplicate/skip rows). +def sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]: + pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"} + table: dict[str, list[dict]] = { + "newest": [pub], + "oldest": [{**pub, "asc": True}], + "views": [{"expr": Video.view_count, "asc": False, "nullable": True, "kind": "num"}], + "views_asc": [{"expr": Video.view_count, "asc": True, "nullable": True, "kind": "num"}], + "duration_desc": [{"expr": Video.duration_seconds, "asc": False, "nullable": True, "kind": "num"}], + "duration_asc": [{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}], + "title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}], + "title_desc": [{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}], + "subscribers": [{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}], + "subscribers_asc": [{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}], + # Your per-channel priority (set in the channel manager), newest first within a tier. + # coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row. + "priority": [{"expr": func.coalesce(Subscription.priority, 0), "asc": False, "nullable": False, "kind": "num"}, pub], + "priority_asc": [{"expr": func.coalesce(Subscription.priority, 0), "asc": True, "nullable": False, "kind": "num"}, pub], + # Deterministic per-seed shuffle: the same seed yields the same total order, so + # the cursor stays valid across pages of one shuffled run. + "shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}], + } + # Relevance (search-result ranking) only exists when there's a query to rank against; + # without `rank_expr` it falls through to newest. Ties break by newest, then id. + if rank_expr is not None: + table["relevance"] = [ + {"expr": rank_expr, "asc": False, "nullable": False, "kind": "num"}, + pub, + ] + return table.get(sort) or table["newest"] + + +def order_by(keys: list[dict]): + cols = [] + for k in keys: + c = k["expr"].asc() if k["asc"] else k["expr"].desc() + cols.append(c.nulls_last() if k["nullable"] else c) + cols.append(Video.id.asc()) # unique final tiebreaker + return cols + + +def keyset_predicate(keys: list[dict], cursor_id: str): + """WHERE clause selecting rows strictly AFTER the cursor in the sort order. + + Lexicographic over the key columns then id, NULL-aware for `nulls_last`: a NULL + sorts after every non-NULL value, so a cursor sitting on a non-NULL value must + also pull in the NULL group, and equality treats NULL == NULL.""" + def eq(k): + return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"] + + def after(k): + if k["value"] is None: + return false() # nulls are last; nothing sorts strictly after them + cmp = k["expr"] > k["value"] if k["asc"] else k["expr"] < k["value"] + return or_(cmp, k["expr"].is_(None)) if k["nullable"] else cmp + + branches = [] + for j in range(len(keys)): + branches.append(and_(*[eq(keys[i]) for i in range(j)], after(keys[j]))) + branches.append(and_(*[eq(k) for k in keys], Video.id > cursor_id)) + return or_(*branches) + + +def encode_cursor(keys: list[dict], row) -> str: + vals = [] + for i, k in enumerate(keys): + v = getattr(row, f"_k{i}") + vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v) + payload = json.dumps({"k": vals, "id": row.id}) + return base64.urlsafe_b64encode(payload.encode()).decode() + + +def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]: + try: + payload = json.loads(base64.urlsafe_b64decode(cursor.encode())) + raw = payload["k"] + if len(raw) != len(keys): + raise ValueError("key arity mismatch") + vals = [ + datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v + for k, v in zip(keys, raw) + ] + return vals, payload["id"] + except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc: + # Framework-free: the route handler translates this into an HTTP 400 so the + # read-model stays web-layer-agnostic (see routes/feed.get_feed). + raise ValueError("Invalid feed cursor") from exc diff --git a/backend/app/models.py b/backend/app/models.py index 3bf35918..65078e47 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -995,6 +995,13 @@ class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin): kind: Mapped[str] = mapped_column(String(16)) # "movie" | "show" enabled: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true") synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # HU-overlay support (E5 S5). `metadata_lang` tags the section's metadata language. An OVERLAY + # library (overlay_of set) is the Hungarian-metadata twin of another section: it is never browsable + # on its own — its titles' HU fields are written onto the matched EN rows (paired by `guid`). + metadata_lang: Mapped[str] = mapped_column(String(8), default="en", server_default="en") + overlay_of: Mapped[int | None] = mapped_column( + ForeignKey("plex_libraries.id", ondelete="SET NULL"), index=True + ) class PlexShow(Base, TimestampMixin, UpdatedAtMixin): @@ -1007,6 +1014,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): Index("ix_plex_shows_genres_gin", "genres", postgresql_using="gin"), Index("ix_plex_shows_directors_gin", "directors", postgresql_using="gin"), Index("ix_plex_shows_cast_gin", "cast_names", postgresql_using="gin"), + Index("ix_plex_shows_genres_hu_gin", "genres_hu", postgresql_using="gin"), # ix_plex_shows_collections_gin already created with collections (migration 0047). ) @@ -1015,6 +1023,9 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): library_id: Mapped[int] = mapped_column( ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True ) + # Canonical external id (e.g. "tmdb:12345") — the key that pairs this EN row with its HU-overlay + # twin during sync (E5 S5). Indexed for the guid->row overlay match. + guid: Mapped[str | None] = mapped_column(String(64), index=True) title: Mapped[str] = mapped_column(String(512)) summary: Mapped[str | None] = mapped_column(Text) year: Mapped[int | None] = mapped_column(Integer) @@ -1037,6 +1048,17 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector # Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so + # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. + imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating) + 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}] + # 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)) + summary_hu: Mapped[str | None] = mapped_column(Text) + genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) search_vector: Mapped[object | None] = mapped_column( TSVECTOR, Computed( @@ -1046,6 +1068,15 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin): persisted=True, ), ) + search_vector_hu: Mapped[object | None] = mapped_column( + TSVECTOR, + Computed( + "setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || " + "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " + "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')", + persisted=True, + ), + ) class PlexSeason(Base, TimestampMixin, UpdatedAtMixin): @@ -1077,6 +1108,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"), Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"), Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"), + Index("ix_plex_items_genres_hu_gin", "genres_hu", postgresql_using="gin"), ) id: Mapped[int] = mapped_column(primary_key=True) @@ -1085,6 +1117,9 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True ) kind: Mapped[str] = mapped_column(String(16), index=True) # "movie" | "episode" + # Canonical external id (e.g. "tmdb:12345") — pairs a MOVIE row with its HU-overlay twin during + # sync (E5 S5); indexed for the guid->row match. Episodes carry it too but it's unused for them. + guid: Mapped[str | None] = mapped_column(String(64), index=True) show_id: Mapped[int | None] = mapped_column( ForeignKey("plex_shows.id", ondelete="CASCADE"), index=True ) @@ -1130,6 +1165,17 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): people_text: Mapped[str | None] = mapped_column(Text) # Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3. people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Rich-fetch-only detail fields, persisted by the same backfill that fills the full cast (E5 S5b) so + # the info page serves from this row instead of a live plex.metadata() call. NULL until enriched. + imdb_rating: Mapped[float | None] = mapped_column(Float) # IMDb-specific score (vs `rating` = audienceRating) + 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}] + # 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)) + summary_hu: Mapped[str | None] = mapped_column(Text) + genres_hu: Mapped[list | None] = mapped_column(JSONB(none_as_null=True)) search_vector: Mapped[object | None] = mapped_column( TSVECTOR, Computed( @@ -1139,6 +1185,15 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin): persisted=True, ), ) + search_vector_hu: Mapped[object | None] = mapped_column( + TSVECTOR, + Computed( + "setweight(to_tsvector('public.unaccent_simple', coalesce(title_hu, '')), 'A') || " + "setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || " + "setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary_hu, ''), 1000)), 'C')", + persisted=True, + ), + ) class PlexPerson(Base, TimestampMixin, UpdatedAtMixin): diff --git a/backend/app/plex/cards.py b/backend/app/plex/cards.py new file mode 100644 index 00000000..e8abf9a4 --- /dev/null +++ b/backend/app/plex/cards.py @@ -0,0 +1,65 @@ +"""Plex list/leaf card serializers: the small `model -> dict` projections the library grid, +playlists, and detail views render. Extracted from routes/plex/browse.py so the row-shaping +lives beside the other Plex read-model modules (detail.py, query.py) rather than inside the +router. Pure functions — no DB, no request state.""" + +from app.models import PlexItem, PlexShow, PlexState + + +def movie_card(it: PlexItem, st: PlexState | None) -> dict: + return { + "id": it.rating_key, + "type": "movie", + "title": it.title, + "year": it.year, + "duration_seconds": it.duration_s, + "thumb": f"/api/plex/image/{it.rating_key}", + "playable": it.playable, + "status": st.status if st else "new", + "position_seconds": st.position_seconds if st else 0, + } + + +def show_card(sh: PlexShow, status: str = "new") -> dict: + return { + "id": sh.rating_key, + "type": "show", + "title": sh.title, + "year": sh.year, + "thumb": f"/api/plex/image/{sh.rating_key}", + "season_count": sh.child_count, + # Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge. + "status": status, + } + + +def episode_card(e: PlexItem, st: PlexState | None) -> dict: + return { + "id": e.rating_key, + "type": "episode", + "title": e.title, + "summary": e.summary, + "season_number": e.season_number, + "episode_number": e.episode_number, + "duration_seconds": e.duration_s, + "thumb": f"/api/plex/image/{e.rating_key}", + "playable": e.playable, + "status": st.status if st else "new", + "position_seconds": st.position_seconds if st else 0, + } + + +def leaf_card( + it: PlexItem, + st: PlexState | None, + show_title: str | None = None, + show_rk: str | None = None, +) -> dict: + """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds. + `show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.""" + if it.kind == "episode": + card = episode_card(it, st) + card["show_title"] = show_title + card["show_id"] = show_rk + return card + return movie_card(it, st) diff --git a/backend/app/plex/client.py b/backend/app/plex/client.py index e0341a3b..0c5afdae 100644 --- a/backend/app/plex/client.py +++ b/backend/app/plex/client.py @@ -85,6 +85,10 @@ class PlexClient: f"/library/sections/{key}/all", params={ "type": item_type, + # includeGuids=1 carries each item's Guid array (tmdb/imdb/tvdb ids) in the cheap + # listing, so the sync can capture the canonical guid without a per-item rich fetch — + # the pairing key for the HU overlay (E5 S5). + "includeGuids": 1, "X-Plex-Container-Start": start, "X-Plex-Container-Size": size, }, diff --git a/backend/app/plex/detail.py b/backend/app/plex/detail.py new file mode 100644 index 00000000..0f9887fc --- /dev/null +++ b/backend/app/plex/detail.py @@ -0,0 +1,59 @@ +"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that +the detail endpoints serve from the DB row (E5 S5b). + +Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so +the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent. +`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row +(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`).""" + +from app.plex import people + + +def rich_detail(meta: dict) -> dict: + """Pull the info-page extras out of a full Plex metadata dict: an IMDb score + link, content + rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any + missing field is just omitted.""" + # Rating: IMDb-ONLY — the explicit `imdb://` entry in the Rating array, or NULL. We deliberately do + # NOT fall back to audienceRating/rating here: that is a Plex audience score, not an IMDb score, and + # labelling it "imdb_rating" mislabels it. The audience score is served separately (item/show detail + # expose the `rating` column) so the UI can show it when there's no IMDb score. + imdb_rating = None + for r in meta.get("Rating") or []: + if str(r.get("image") or "").startswith("imdb://"): + try: + imdb_rating = round(float(r.get("value")), 1) + except (TypeError, ValueError): + pass + break + # IMDb id (tt…) from the Guid list → a clickable link. + imdb_id = None + for g in meta.get("Guid") or []: + gid = str(g.get("id") or "") + if gid.startswith("imdb://"): + imdb_id = gid.split("imdb://", 1)[1] + break + return { + "imdb_rating": imdb_rating, + "imdb_id": imdb_id, + "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, + "content_rating": meta.get("contentRating"), + "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], + "directors": [ + d.get("tag") for d in (meta.get("Director") or []) if d.get("tag") + ][:4], + "studio": meta.get("studio"), + "tagline": meta.get("tagline"), + "cast": people.cast_from_meta(meta), + } + + +def apply_rich_detail(row, meta: dict) -> None: + """Persist the rich-ONLY detail fields onto a movie/show row so the info page serves them from the + DB instead of a live Plex call. Call wherever a rich metadata dict is fetched (the cast backfill + + the info-page lazy enrich). Empty cast persists as SQL NULL (none_as_null), so an EN fallback and + the `full_cast IS NULL` "not yet enriched" signal stay meaningful.""" + d = rich_detail(meta) + row.imdb_rating = d["imdb_rating"] + row.imdb_id = d["imdb_id"] + row.tagline = d["tagline"] + row.full_cast = d["cast"] or None diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index 2dbeb2a3..01a4a50d 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import Session from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow from app.plex.cache import CACHE_DIR +from app.plex.lookups import by_rating_key _EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")] _CT_EXT = {ct: ext for ext, ct in _EXT_CT} @@ -24,16 +25,16 @@ SIZES = {"thumb": (400, 600), "art": (1280, 720)} def key_for(db: Session, rating_key: str, variant: str) -> str | None: """The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys are proxyable — this is not an open image proxy.""" - it = db.query(PlexItem).filter_by(rating_key=rating_key).first() + it = by_rating_key(db, PlexItem, rating_key) if it is not None: return it.art_key if variant == "art" else it.thumb_key - sh = db.query(PlexShow).filter_by(rating_key=rating_key).first() + sh = by_rating_key(db, PlexShow, rating_key) if sh is not None: return sh.art_key if variant == "art" else sh.thumb_key - se = db.query(PlexSeason).filter_by(rating_key=rating_key).first() + se = by_rating_key(db, PlexSeason, rating_key) if se is not None: return se.thumb_key - col = db.query(PlexCollection).filter_by(rating_key=rating_key).first() + col = by_rating_key(db, PlexCollection, rating_key) if col is not None: return col.thumb_key return None diff --git a/backend/app/plex/lookups.py b/backend/app/plex/lookups.py new file mode 100644 index 00000000..577f0ba6 --- /dev/null +++ b/backend/app/plex/lookups.py @@ -0,0 +1,16 @@ +"""Small shared Plex row lookups, so the get-by-rating_key pattern lives in one place.""" +from typing import TypeVar + +from sqlalchemy import select +from sqlalchemy.orm import Session + +_M = TypeVar("_M") + + +def by_rating_key(db: Session, model: type[_M], rating_key: str) -> _M | None: + """The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex + tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call + sites keep their `Model | None` static type.""" + return db.execute( + select(model).where(model.rating_key == str(rating_key)) + ).scalars().first() diff --git a/backend/app/plex/people.py b/backend/app/plex/people.py index 7f5652c7..758ce276 100644 --- a/backend/app/plex/people.py +++ b/backend/app/plex/people.py @@ -5,7 +5,7 @@ re-sync (plex.sync), so the two paths never diverge.""" from urllib.parse import quote -from sqlalchemy import text +from sqlalchemy import select, text from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -55,7 +55,10 @@ def enrich_row_people(db: Session, row, cast: list[dict]) -> bool: dirty = True # Headshot cache: upsert name -> proxied thumb (a later fetch fills a prior NULL; names without a # whitelisted photo still get a row so a card can show a placeholder). - existing = {p.name: p for p in db.query(PlexPerson).filter(PlexPerson.name.in_(names))} + existing = { + p.name: p + for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(names))).scalars() + } missing: dict[str, str | None] = {} for c in cast: nm, th = c.get("name"), c.get("thumb") diff --git a/backend/app/plex/query.py b/backend/app/plex/query.py new file mode 100644 index 00000000..bbc539c7 --- /dev/null +++ b/backend/app/plex/query.py @@ -0,0 +1,191 @@ +"""Plex library query-shaping: the full-text field-scope rechecks and the sidebar metadata +filters that /library and /facets share. Extracted from routes/plex/browse.py so the query +builder has one home (and so the HU-overlay column selection lands here, not in the router). +Pure query fragments — every function returns SQLAlchemy expressions or a params dict.""" + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlalchemy import and_, func, or_, type_coerce +from sqlalchemy.dialects.postgresql import JSONB + +TS_CONFIG = "public.unaccent_simple" + +# --- Language-aware column pickers (E5 S6) ---------------------------------- +# +# The HU-overlay twin (E5 S5) stores Hungarian metadata in `*_hu` columns on the SAME EN row. When +# the app language is `hu` we serve those, falling back to the EN column where a row has no HU twin. +# Two flavours of picker, on purpose: +# * DISPLAY / FILTER (`title_col`/`genres_col`): `coalesce(*_hu, *)` — an EN fallback so a row with +# no HU metadata still shows (and filters on) its English value. (The detail endpoints serve the +# HU summary directly off the loaded row, so no `summary_col` SQL helper is needed.) +# * SEARCH (`vector_col` + `field_recheck`): the raw `*_hu` column coalesced to '' — MIRRORING the +# generated `search_vector_hu` definition (which uses `coalesce(title_hu, '')`, NOT an EN +# fallback), so the field recheck stays a strict subset of the GIN vector prefilter (the S2 +# invariant). `people_text` is language-neutral and shared by both vectors. + + +def title_col(model, lang: str): + return func.coalesce(model.title_hu, model.title) if lang == "hu" else model.title + + +def genres_col(model, lang: str): + # type_coerce so the JSONB comparator (`.contains` / `@>`, GIN-backed) survives the coalesce. + if lang == "hu": + return type_coerce(func.coalesce(model.genres_hu, model.genres), JSONB) + return model.genres + + +def vector_col(model, lang: str): + # RANKING vector. In HU we rank by the Hungarian vector; a row matched only via its English vector + # (see `vector_match`) gets rank 0 and sorts last, which is the intended HU-first ordering. + return model.search_vector_hu if lang == "hu" else model.search_vector + + +def vector_match(model, lang: str, tsq): + """The full-text WHERE clause. In HU, match EITHER vector so a row with no HU twin stays findable + by its English title (search_vector_hu has no title tokens for such a row); EN matches only the EN + vector. Ranking still uses `vector_col` (the HU vector in HU mode).""" + if lang == "hu": + return or_( + model.search_vector_hu.op("@@")(tsq), model.search_vector.op("@@")(tsq) + ) + return model.search_vector.op("@@")(tsq) + + +# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE +# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to +# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column +# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions +# mirror the `search_vector` Computed definition (same `TS_CONFIG`, same `left(summary, 1000)` +# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled +# separately in the query body (it targets episode rows, not the movie/show grid). +FIELD_COLUMNS = ("title", "people", "summary", "episode") + + +def _title_tsv(col): + return func.to_tsvector(TS_CONFIG, func.coalesce(col, "")) + + +def _summary_tsv(col): + return func.to_tsvector(TS_CONFIG, func.left(func.coalesce(col, ""), 1000)) + + +def field_recheck(model, field: str, tsq, lang: str = "en"): + """A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None. + Each per-field tsvector mirrors the generated `search_vector[_hu]` definition (same TS_CONFIG, + same `left(summary, 1000)`). In HU, title/summary re-check BOTH the HU and EN columns so a scoped + hit stays a strict subset of `vector_match`'s HU union; `people` is language-neutral.""" + hu = lang == "hu" + if field == "title": + cols = [model.title_hu, model.title] if hu else [model.title] + return or_(*[_title_tsv(c).op("@@")(tsq) for c in cols]) + if field == "people": + return func.to_tsvector(TS_CONFIG, func.coalesce(model.people_text, "")).op("@@")(tsq) + if field == "summary": + cols = [model.summary_hu, model.summary] if hu else [model.summary] + return or_(*[_summary_tsv(c).op("@@")(tsq) for c in cols]) + return None + + +def split_csv(v: str | None) -> list[str]: + return [s.strip() for s in v.split(",") if s.strip()] if v else [] + + +_ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365} + + +def added_cutoff(within: str | None) -> datetime | None: + days = _ADDED_WINDOWS.get(within or "") + return datetime.now(timezone.utc) - timedelta(days=days) if days else None + + +def meta_filter_conds(model, p: dict, lang: str = "en") -> list: + """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for + `p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a + list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets. + Genre chips are language-specific strings, so they filter the active-language genre column.""" + conds: list = [] + gsel = split_csv(p.get("genres")) + if gsel: + gcol = genres_col(model, lang) + gc = [gcol.contains([g]) for g in gsel] + conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc)) + crs = split_csv(p.get("content_ratings")) + if crs: + conds.append(model.content_rating.in_(crs)) + if p.get("year_min") is not None: + conds.append(model.year >= p["year_min"]) + if p.get("year_max") is not None: + conds.append(model.year <= p["year_max"]) + if p.get("rating_min") is not None: + # Filter by the SAME score the UI shows on the badge (IMDb preferred, audience fallback), so a + # ratingMin picked from a displayed IMDb score narrows the right column instead of the raw + # audience `rating`. Both are 0–10, so coalesce is scale-consistent. + conds.append(func.coalesce(model.imdb_rating, model.rating) >= p["rating_min"]) + cutoff = added_cutoff(p.get("added_within")) + if cutoff is not None: + conds.append(model.added_at >= cutoff) + for d in split_csv(p.get("directors")): # AND + conds.append(model.directors.contains([d])) + alist = split_csv(p.get("actors")) + if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode + ac = [model.cast_names.contains([a]) for a in alist] + conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac)) + stds = split_csv(p.get("studios")) + if stds: # OR + conds.append(model.studio.in_(stds)) + if p.get("collection"): + conds.append(model.collection_keys.contains([p["collection"]])) + return conds + + +def apply_meta_filters(q, model, p: dict, lang: str = "en"): + conds = meta_filter_conds(model, p, lang) + return q.where(*conds) if conds else q + + +@dataclass +class LibraryFilters: + """The shared sidebar filter query-params for /library + /facets, declared once and injected via + ``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included + for /facets' wsq but ignored by `meta_filter_conds` (both callers apply duration separately).""" + + genres: str | None = None + genre_mode: str = "any" + content_ratings: str | None = None + year_min: int | None = None + year_max: int | None = None + rating_min: float | None = None + duration_min: int | None = None + duration_max: int | None = None + added_within: str | None = None + directors: str | None = None + actors: str | None = None + actor_mode: str = ( + "any" # how multiple actors combine: any (OR, default) | all (AND) + ) + studios: str | None = None + collection: str | None = None + + def as_dict(self) -> dict: + return { + "genres": self.genres, + "genre_mode": self.genre_mode, + "content_ratings": self.content_ratings, + "year_min": self.year_min, + "year_max": self.year_max, + "rating_min": self.rating_min, + "duration_min": self.duration_min, + "duration_max": self.duration_max, + "added_within": self.added_within, + "directors": self.directors, + "actors": self.actors, + "actor_mode": self.actor_mode, + "studios": self.studios, + "collection": self.collection, + } + + +def like_escape(s: str) -> str: + return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py index fb6497ee..fa762610 100644 --- a/backend/app/plex/rollup.py +++ b/backend/app/plex/rollup.py @@ -3,7 +3,7 @@ Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons. """ -from sqlalchemy import and_, case, func, or_ +from sqlalchemy import and_, case, func, or_, select from sqlalchemy.orm import Session, aliased from app.models import PlexItem, PlexState @@ -47,7 +47,7 @@ def agg_subquery(db: Session, user_id: int): grid by aggregate watch-state and (b) compute each card's badge.""" ps = aliased(PlexState) return ( - db.query( + select( PlexItem.show_id.label("show_id"), func.count(PlexItem.id).label("total"), func.count(case((ps.status == "watched", 1))).label("watched"), @@ -64,7 +64,7 @@ def agg_subquery(db: Session, user_id: int): ).label("inprog"), ) .outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id)) - .filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None)) + .where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None)) .group_by(PlexItem.show_id) ) @@ -72,5 +72,7 @@ def agg_subquery(db: Session, user_id: int): def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]: if not show_ids: return {} - rows = agg_subquery(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all() + rows = db.execute( + 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} diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index f1b9e82a..acdb5e5a 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -12,12 +12,14 @@ Cast + intro/credit markers are fetched lazily on the item-detail/player path (P import logging from datetime import datetime, timezone +from sqlalchemy import select from sqlalchemy.orm import Session from app import sysconfig from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow -from app.plex import people +from app.plex import detail, people from app.plex.client import PlexClient, PlexError, PlexNotConfigured +from app.plex.lookups import by_rating_key log = logging.getLogger("siftlode.plex") @@ -80,6 +82,26 @@ def _rating(meta: dict) -> float | None: return None +# Provider preference for the canonical guid (a stable id shared across a title's language twins). +_GUID_PREFERENCE = ("tmdb", "imdb", "tvdb") + + +def _canonical_guid(meta: dict) -> str | None: + """The canonical external id used to pair an EN title with its HU-overlay twin (E5 S5). Reads the + `Guid` array (present only with includeGuids=1 in the listing), preferring tmdb > imdb > tvdb, + normalised to ':' (e.g. 'tmdb:12345'). None when no known provider id is present.""" + found: dict[str, str] = {} + for g in meta.get("Guid") or []: + raw = str(g.get("id") or "") + for prov in _GUID_PREFERENCE: + if raw.startswith(f"{prov}://"): + found.setdefault(prov, f"{prov}:{raw.split('://', 1)[1]}") + for prov in _GUID_PREFERENCE: + if prov in found: + return found[prov] + return None + + def _media_facts(meta: dict) -> dict: media = meta.get("Media") or [] if not media: @@ -108,21 +130,48 @@ def _enabled_section_keys(db: Session) -> set[str] | None: return keys or None +def _overlay_map(db: Session) -> dict[str, str]: + """The configured HU-overlay pairing: {huSectionKey: enSectionKey} from `plex_overlay_map` + ('huKey:enKey,...'). Each HU section is synced as a non-browsable overlay onto its EN twin. + Malformed pairs are skipped.""" + raw = sysconfig.get_str(db, "plex_overlay_map") + out: dict[str, str] = {} + for pair in raw.split(","): + hu, sep, en = pair.partition(":") + hu, en = hu.strip(), en.strip() + if sep and hu and en: + out[hu] = en + return out + + def sync(db: Session) -> dict: """Full reconcile of the enabled movie/show sections. Returns a small stats dict.""" if not sysconfig.get_bool(db, "plex_enabled"): return {"skipped": "disabled"} - stats = {"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0, "collections": 0} + stats = { + "libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0, + "collections": 0, "overlaid": 0, + } wanted = _enabled_section_keys(db) + overlay_map = _overlay_map(db) # {huKey: enKey} + overlay_keys = set(overlay_map) try: with PlexClient(db) as plex: - for s in plex.sections(): - if s.get("type") not in ("movie", "show"): + sections = { + str(s.get("key")): s + for s in plex.sections() + if s.get("type") in ("movie", "show") + } + libs_by_key: dict[str, PlexLibrary] = {} + # Pass 1 — browsable (non-overlay) sections. HU-overlay sections are handled in pass 2 and + # are NEVER mirrored as browsable libraries of their own. + for key, s in sections.items(): + if key in overlay_keys: continue - key = str(s.get("key")) if wanted is not None and key not in wanted: continue lib = _upsert_library(db, key, s.get("title") or key, s.get("type")) + libs_by_key[key] = lib stats["libraries"] += 1 if s.get("type") == "movie": _sync_movies(db, plex, lib, stats) @@ -133,6 +182,24 @@ def sync(db: Session) -> dict: # libraries already mirrored (idempotent — the next run reconciles the rest), instead # of the old single end-of-loop commit that a late failure rolled back entirely. db.commit() + # Pass 2 — HU overlays. The EN twins are now synced (with guids), so write each overlay + # section's Hungarian metadata onto the matched EN rows. Requires the EN section to have been + # synced in pass 1 (in the allowlist / all) and to share the overlay's kind. + for hu_key, en_key in overlay_map.items(): + s = sections.get(hu_key) + en_lib = libs_by_key.get(en_key) + if s is None or en_lib is None or s.get("type") != en_lib.kind: + log.warning( + "Plex overlay skipped: hu=%s en=%s (missing section/EN lib or kind mismatch)", + hu_key, en_key, + ) + continue + lib = _upsert_library(db, hu_key, s.get("title") or hu_key, s.get("type")) + lib.metadata_lang = "hu" + lib.overlay_of = en_lib.id + db.flush() + _sync_overlay(db, plex, lib, en_lib.id, stats) + db.commit() # Rich-fetch a bounded batch of not-yet-enriched titles' FULL cast, so the whole library # becomes filterable over successive runs (the cheap listing above only stored the top ~3). try: @@ -165,20 +232,20 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT shared enrich_row_people). Oldest-id first, movies before shows. Stamps people_enriched_at only on a SUCCESSFUL fetch, so a transient per-item failure retries next run rather than being lost. Returns how many items were enriched this run.""" - movies = ( - db.query(PlexItem) - .filter(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None)) + movies = db.execute( + select(PlexItem) + .where(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None)) .order_by(PlexItem.id) .limit(limit) - .all() - ) + ).scalars().all() remaining = limit - len(movies) shows = ( - db.query(PlexShow) - .filter(PlexShow.people_enriched_at.is_(None)) - .order_by(PlexShow.id) - .limit(remaining) - .all() + db.execute( + select(PlexShow) + .where(PlexShow.people_enriched_at.is_(None)) + .order_by(PlexShow.id) + .limit(remaining) + ).scalars().all() if remaining > 0 else [] ) @@ -191,6 +258,7 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e) continue # leave people_enriched_at NULL so it's retried on a later run people.enrich_row_people(db, row, people.cast_from_meta(meta)) + detail.apply_rich_detail(row, meta) # imdb/tagline/full_cast → served from the row, not live row.people_enriched_at = now n += 1 db.commit() @@ -198,7 +266,9 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary: - lib = db.query(PlexLibrary).filter_by(plex_key=key).first() + lib = db.execute( + select(PlexLibrary).where(PlexLibrary.plex_key == key) + ).scalars().first() if lib is None: lib = PlexLibrary(plex_key=key) db.add(lib) @@ -234,7 +304,12 @@ def _apply_facet_fields(row, meta: dict) -> None: row.originally_available_at = _date(meta.get("originallyAvailableAt")) row.genres = _tags(meta, "Genre") row.directors = _tags(meta, "Director") - row.cast_names = _tags(meta, "Role", limit=20) + # The listing carries only the top ~20 cast, so re-applying it would DOWNGRADE an already-enriched + # full cast (up to 24, grown by the rich backfill/info-page enrich) — and since a sync never clears + # `people_enriched_at`, nothing would regrow it, silently breaking the person-page cast filter for + # names past the top ~20. Only take the listing's cast before the row is enriched; keep it after. + if row.people_enriched_at is None: + row.cast_names = _tags(meta, "Role", limit=20) people = list(dict.fromkeys((row.directors or []) + (row.cast_names or []))) row.people_text = " ".join(people) or None @@ -242,6 +317,9 @@ def _apply_facet_fields(row, meta: dict) -> None: def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None: row.library_id = lib_id row.kind = kind + # EN<->HU pairing key (movies; harmless/unused on episodes). Keep the prior guid if this listing + # carries none (a server ignoring includeGuids mustn't wipe the overlay's only match key). + row.guid = _canonical_guid(meta) or row.guid row.title = (meta.get("title") or "").strip() or "Untitled" row.summary = meta.get("summary") row.year = meta.get("year") @@ -261,7 +339,12 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None: def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None: - existing = {r.rating_key: r for r in db.query(PlexItem).filter_by(library_id=lib.id, kind="movie")} + existing = { + r.rating_key: r + for r in db.execute( + select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie") + ).scalars() + } for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE): rk = str(meta.get("ratingKey") or "") if not rk: @@ -276,7 +359,12 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) - def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None: # 1) shows - shows = {r.rating_key: r for r in db.query(PlexShow).filter_by(library_id=lib.id)} + shows = { + r.rating_key: r + for r in db.execute( + select(PlexShow).where(PlexShow.library_id == lib.id) + ).scalars() + } for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW): rk = str(meta.get("ratingKey") or "") if not rk: @@ -286,6 +374,7 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> db.add(sh) shows[rk] = sh sh.library_id = lib.id + sh.guid = _canonical_guid(meta) or sh.guid # EN<->HU pairing key; keep prior if listing has none sh.title = (meta.get("title") or "").strip() or "Untitled" sh.summary = meta.get("summary") sh.year = meta.get("year") @@ -299,7 +388,12 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> show_id = {rk: sh.id for rk, sh in shows.items()} # 2) seasons - seasons = {r.rating_key: r for r in db.query(PlexSeason).join(PlexShow).filter(PlexShow.library_id == lib.id)} + seasons = { + r.rating_key: r + for r in db.execute( + select(PlexSeason).join(PlexShow).where(PlexShow.library_id == lib.id) + ).scalars() + } for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON): rk = str(meta.get("ratingKey") or "") sid = show_id.get(str(meta.get("parentRatingKey") or "")) @@ -318,7 +412,12 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> season_id = {rk: se.id for rk, se in seasons.items()} # 3) episodes (playable leaves) - episodes = {r.rating_key: r for r in db.query(PlexItem).filter_by(library_id=lib.id, kind="episode")} + episodes = { + r.rating_key: r + for r in db.execute( + select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "episode") + ).scalars() + } for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE): rk = str(meta.get("ratingKey") or "") if not rk: @@ -335,6 +434,59 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> db.flush() +def _sync_overlay( + db: Session, plex: PlexClient, lib: PlexLibrary, en_lib_id: int, stats: dict +) -> None: + """Write an overlay section's Hungarian metadata onto its EN twin's rows, matched by guid. Creates + NO browsable rows — an overlay library is never listed on its own. Only title/summary/genres differ + by language; the language-neutral people_text feeds both search vectors. + + This is a full RECONCILE: every guid-bearing EN row's HU fields are cleared first, then the HU + section repopulates the matches — so an EN row whose HU twin vanished (or was re-guid'd) falls back + to EN instead of keeping stale Hungarian metadata. HU items with no EN match are skipped.""" + if lib.kind == "movie": + rows = db.execute( + select(PlexItem).where( + PlexItem.library_id == en_lib_id, + PlexItem.kind == "movie", + PlexItem.guid.is_not(None), + ) + ).scalars() + item_type = _TYPE_MOVIE + else: + rows = db.execute( + select(PlexShow).where( + PlexShow.library_id == en_lib_id, PlexShow.guid.is_not(None) + ) + ).scalars() + item_type = _TYPE_SHOW + # guid -> the EN rows carrying it (a list: two editions of one film can share a guid; both overlay). + by_guid: dict[str, list] = {} + for r in rows: + by_guid.setdefault(r.guid, []).append(r) + # Reconcile step: clear HU fields on all guid-bearing rows. Only rows that actually held HU data + # become dirty (SQLAlchemy skips a no-net-change UPDATE), so this is cheap; matches are re-set below. + for group in by_guid.values(): + for row in group: + row.title_hu = row.summary_hu = row.genres_hu = None + seen: set[str] = set() # first HU entry for a guid wins; a dup HU item can't overwrite or double-count + for meta in _paginate(plex, lib.plex_key, item_type): + g = _canonical_guid(meta) + targets = by_guid.get(g) if g else None + if not targets or g in seen: + continue + seen.add(g) + title_hu = (meta.get("title") or "").strip() or None + summary_hu = meta.get("summary") + genres_hu = _tags(meta, "Genre") + for row in targets: + row.title_hu = title_hu + row.summary_hu = summary_hu + row.genres_hu = genres_hu + stats["overlaid"] = stats.get("overlaid", 0) + 1 + db.flush() + + def _upsert_collection( db: Session, existing: PlexCollection | None, rk: str, lib_id: int, meta: dict ) -> PlexCollection: @@ -359,17 +511,21 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) collection's membership on the affected member rows.""" meta = plex.metadata(rk) or {} col = _upsert_collection( - db, db.query(PlexCollection).filter_by(rating_key=rk).first(), rk, lib.id, meta + db, by_rating_key(db, PlexCollection, rk), rk, lib.id, meta ) Model = PlexItem if lib.kind == "movie" else PlexShow new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")} - current = db.query(Model).filter(Model.collection_keys.contains([rk])).all() + current = db.execute( + select(Model).where(Model.collection_keys.contains([rk])) + ).scalars().all() for row in current: # drop rk from rows that are no longer members if row.rating_key not in new_members: row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None to_add = new_members - {r.rating_key for r in current} if to_add: - for row in db.query(Model).filter(Model.rating_key.in_(to_add)): + for row in db.execute( + select(Model).where(Model.rating_key.in_(to_add)) + ).scalars(): row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rk])) db.flush() return col @@ -378,9 +534,11 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None: """Local cleanup after a collection is deleted from Plex: strip rk from members + drop the row.""" Model = PlexItem if lib.kind == "movie" else PlexShow - for row in db.query(Model).filter(Model.collection_keys.contains([rk])): + for row in db.execute( + select(Model).where(Model.collection_keys.contains([rk])) + ).scalars(): row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None - col = db.query(PlexCollection).filter_by(rating_key=rk).first() + col = by_rating_key(db, PlexCollection, rk) if col is not None: db.delete(col) db.flush() @@ -391,7 +549,12 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile: collections gone from Plex are pruned, and each member row's keys are rebuilt from scratch.""" cols = plex.collections(lib.plex_key) - existing = {c.rating_key: c for c in db.query(PlexCollection).filter_by(library_id=lib.id)} + existing = { + c.rating_key: c + for c in db.execute( + select(PlexCollection).where(PlexCollection.library_id == lib.id) + ).scalars() + } seen: set[str] = set() membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...] for meta in cols: @@ -410,8 +573,8 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di if rk not in seen: db.delete(col) # Rebuild collection_keys on every member row of this library (clear + set). - for row in db.query(PlexItem).filter_by(library_id=lib.id): + for row in db.execute(select(PlexItem).where(PlexItem.library_id == lib.id)).scalars(): row.collection_keys = membership.get(row.rating_key) or None - for row in db.query(PlexShow).filter_by(library_id=lib.id): + for row in db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars(): row.collection_keys = membership.get(row.rating_key) or None db.flush() diff --git a/backend/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py index 6e1c3d7e..a72d5b01 100644 --- a/backend/app/plex/watch_sync.py +++ b/backend/app/plex/watch_sync.py @@ -16,7 +16,7 @@ history + onDeck, last-write-wins) build on this and land in later ships. import logging from datetime import datetime, timedelta, timezone -from sqlalchemy import func +from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -61,7 +61,10 @@ def _plex_watch_to_state( def _rk_to_id(db: Session) -> dict[str, int]: """The rating_key → PlexItem.id lookup used to resolve a Plex leaf back to its mirrored row.""" - return {rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)} + return { + rk: iid + for rk, iid in db.execute(select(PlexItem.rating_key, PlexItem.id)).all() + } def _scan_plex_states( @@ -162,7 +165,9 @@ def link_for_push(db: Session, user_id: int) -> PlexLink | None: """The active owner watch-push link for this user, or None when push is off / not the owner. Push is gated on `initial_import_done` so we never push a half-known local state over Plex's authoritative one before the one-time "Plex is master" import has reconciled them.""" - link = db.query(PlexLink).filter_by(user_id=user_id).first() + link = db.execute( + select(PlexLink).where(PlexLink.user_id == user_id) + ).scalars().first() if link and link.uses_admin and link.sync_enabled and link.initial_import_done: return link return None @@ -217,7 +222,11 @@ def push_state_to_plex( # Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be # nothing to flag; that's fine (unscrobble still happened). Skip the flag if the row was # re-edited since we captured `expect_ts` (leave it dirty for its own push / the reconcile). - st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id == item_id + ) + ).scalars().first() if st is not None and _row_matches(st, expect_ts): st.synced_to_plex = True db.commit() @@ -272,9 +281,11 @@ def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) - # Flag every successfully-pushed row that still exists (an "unwatched" deletes the row, so # only the "watched" changes have a row to flag). if synced_ids: - db.query(PlexState).filter( - PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids) - ).update({PlexState.synced_to_plex: True}, synchronize_session=False) + db.execute( + update(PlexState) + .where(PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids)) + .values(synced_to_plex=True) + ) db.commit() @@ -290,11 +301,13 @@ _LWW_TOLERANCE_S = 90 def _active_sync_links(db: Session) -> list[PlexLink]: """Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so P5b friend links slot in later without touching this loop.""" - return ( - db.query(PlexLink) - .filter_by(uses_admin=True, sync_enabled=True, initial_import_done=True) - .all() - ) + return db.execute( + select(PlexLink).where( + PlexLink.uses_admin.is_(True), + PlexLink.sync_enabled.is_(True), + PlexLink.initial_import_done.is_(True), + ) + ).scalars().all() def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int: @@ -340,7 +353,11 @@ def _pull_apply( """Bring one Plex-side positive signal into Siftlode under last-write-wins. Returns "watched"/"in_progress" if it changed the row, else None. Never clears a row (un-watch is the full reconcile's job) — this only pulls in watched/resume.""" - st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id == item_id + ) + ).scalars().first() if _same_state(st, status, pos): if st is not None and not st.synced_to_plex: st.synced_to_plex = True # already matches Plex → just settle the flag, no churn @@ -368,17 +385,18 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int: # re-push doesn't need an extra per-item plex.metadata() round-trip just to fill set_timeline's # duration argument. info_by_id: dict[int, tuple[str, int | None]] = { - iid: (rk, dur) for iid, rk, dur in db.query(PlexItem.id, PlexItem.rating_key, PlexItem.duration_s) + iid: (rk, dur) + for iid, rk, dur in db.execute( + select(PlexItem.id, PlexItem.rating_key, PlexItem.duration_s) + ).all() } - dirty = ( - db.query(PlexState) - .filter( + dirty = db.execute( + select(PlexState).where( PlexState.user_id == link.user_id, PlexState.synced_to_plex.is_(False), PlexState.status != "hidden", ) - .all() - ) + ).scalars().all() n = 0 for st in dirty: info = info_by_id.get(st.item_id) @@ -477,7 +495,9 @@ def run_plex_watch_reconcile(db: Session) -> dict: # Siftlode rows Plex no longer has a record for: clear the ones that were mirrored # (Plex un-watched them); leave hidden + dirty (dirty gets re-pushed just below). - for st in db.query(PlexState).filter_by(user_id=link.user_id).all(): + for st in db.execute( + select(PlexState).where(PlexState.user_id == link.user_id) + ).scalars().all(): if st.item_id in plex_states or st.status == "hidden": continue if st.synced_to_plex: diff --git a/backend/app/routes/audit.py b/backend/app/routes/audit.py index 9e7a4ef5..493f68db 100644 --- a/backend/app/routes/audit.py +++ b/backend/app/routes/audit.py @@ -9,9 +9,9 @@ from sqlalchemy.orm import Session from app import audit from app.audit import AuditAction +from app.auth import admin_user from app.db import get_db from app.models import AuditLog, User -from app.routes.admin import admin_user from app.utils import iso router = APIRouter(prefix="/api/admin/audit", tags=["admin"]) diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index 7b81fb49..fa4148e5 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -10,7 +10,7 @@ from sqlalchemy import func, select, update from sqlalchemy.orm import Session from app import quota -from app.auth import current_user, require_human, require_write_scope +from app.auth import admin_user, current_user, require_human, require_write_scope from app.db import get_db from app.models import ( LIVE_OR_UPCOMING, @@ -26,7 +26,6 @@ from app.models import ( Video, ) from app.routes._common import owned_or_404 -from app.routes.admin import admin_user from app.sync.explore import explore_ingest_page from app.sync.runner import backfill_channel_now, run_recent_backfill from app.sync.subscriptions import apply_channel_details diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py index 95131624..e2763337 100644 --- a/backend/app/routes/config.py +++ b/backend/app/routes/config.py @@ -11,9 +11,9 @@ from app import audit from app import email as email_mod from app import sysconfig from app.audit import AuditAction +from app.auth import admin_user from app.db import get_db from app.models import User -from app.routes.admin import admin_user router = APIRouter(prefix="/api/admin/config", tags=["admin"]) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 7e555f11..49303b72 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -1,29 +1,26 @@ -import base64 -import binascii -import json -import re -from datetime import date, datetime, timedelta, timezone +from datetime import date, datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel -from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.dialects.postgresql import insert as pg_insert -from sqlalchemy.orm import Session, aliased +from sqlalchemy.orm import Session from sqlalchemy.orm.exc import StaleDataError from app import quota from app.auth import current_user from app.db import get_db +from app.feedquery import ( + decode_cursor, + encode_cursor, + filtered_query, + keyset_predicate, + order_by, + serialize, + sort_keys, +) from app.models import ( - LIVE_OR_UPCOMING, - BlockedChannel, - Channel, ChannelTag, - ExploredChannel, - Playlist, - PlaylistItem, - SearchFind, - Subscription, Tag, User, Video, @@ -47,314 +44,6 @@ PROGRESS_MIN_SECONDS = 5 FINISH_MARGIN_SECONDS = 10 -def _channel_url(channel_id: str, handle: str | None) -> str: - if handle and handle.startswith("@"): - return f"https://www.youtube.com/{handle}" - return f"https://www.youtube.com/channel/{channel_id}" - - -def _saved_expr(user: User): - """A correlated EXISTS labelled `saved`: is this video in the user's Watch later list? - (Watch later is the built-in playlist that replaced the old per-video 'saved' status.)""" - return ( - select(1) - .select_from(PlaylistItem) - .join(Playlist, Playlist.id == PlaylistItem.playlist_id) - .where( - Playlist.user_id == user.id, - Playlist.kind == "watch_later", - PlaylistItem.video_id == Video.id, - ) - .exists() - .label("saved") - ) - - -def feed_columns(user: User, state) -> list: - """The shared feed-row projection (joined Channel fields + per-user watch state + - `saved`), consumed by both the feed query and the playlist-detail query so a new column - is added in exactly one place. `state` is the caller's aliased VideoState (outer-joined).""" - return [ - Video.id, - Video.title, - Video.channel_id, - Channel.title.label("channel_title"), - Channel.thumbnail_url.label("channel_thumbnail"), - Channel.handle.label("channel_handle"), - Video.published_at, - Video.thumbnail_url, - Video.duration_seconds, - Video.view_count, - Video.is_short, - Video.live_status, - func.coalesce(state.status, "new").label("status"), - func.coalesce(state.position_seconds, 0).label("position_seconds"), - _saved_expr(user), - ] - - -def serialize(row) -> dict: - return { - "id": row.id, - "title": row.title, - "channel_id": row.channel_id, - "channel_title": row.channel_title, - "channel_thumbnail": row.channel_thumbnail, - "channel_url": _channel_url(row.channel_id, row.channel_handle), - "published_at": iso(row.published_at), - "thumbnail_url": row.thumbnail_url, - "duration_seconds": row.duration_seconds, - "view_count": row.view_count, - "is_short": row.is_short, - "live_status": row.live_status, - "status": row.status or "new", - "position_seconds": row.position_seconds or 0, - "saved": bool(getattr(row, "saved", False)), - "watch_url": f"https://www.youtube.com/watch?v={row.id}", - } - - -def _filtered_query( - db: Session, - user: User, - *, - tags: list[int], - tag_mode: str, - channel_ids: list[str], - q: str | None, - min_duration: int | None, - max_duration: int | None, - max_age_days: int | None, - published_after: date | None, - published_before: date | None, - show_normal: bool, - include_shorts: bool, - include_live: bool, - show: str, - scope: str = "my", - library_source: str = "organic", - exclude_tag_category: str | None = None, -) -> tuple[Select, object]: - """Build the feed query (joins + all WHERE filters), shared by /feed and /feed/count. - Returns the column-bearing select plus the priority/relevance rank expression for sorting. - - `scope="my"` (default) restricts the feed to the user's own non-hidden subscriptions. - `scope="all"` shows every video in the shared catalog (any user's ingested channels); - the subscription is then only LEFT-joined so per-channel priority still resolves for - channels the user happens to be subscribed to. Per-user watch state stays private in - either mode via the VideoState outer join.""" - state = aliased(VideoState) - status_expr = func.coalesce(state.status, "new") - position_expr = func.coalesce(state.position_seconds, 0) - - query = select(*feed_columns(user, state)).join( - Channel, Channel.id == Video.channel_id - ) - - if scope == "all": - # Whole shared catalog; subscription is optional (only for priority sort). - query = query.outerjoin( - Subscription, - and_( - Subscription.channel_id == Video.channel_id, - Subscription.user_id == user.id, - ), - ) - else: - # "Mine": the user's own videos = their non-hidden subscriptions OR videos they - # surfaced via their own live YouTube search. Both are LEFT-joined so the Source - # filter below can include either or both, and the subscription row stays available - # for the priority sort. - query = query.outerjoin( - Subscription, - and_( - Subscription.channel_id == Video.channel_id, - Subscription.user_id == user.id, - Subscription.hidden.is_(False), - ), - ).outerjoin( - SearchFind, - and_(SearchFind.video_id == Video.id, SearchFind.user_id == user.id), - ) - - query = query.outerjoin( - state, and_(state.video_id == Video.id, state.user_id == user.id) - ) - - # Hide videos the maintenance job has flagged as currently unplayable (deleted/private/ - # abandoned). They're either deleted after a grace period or unhidden if they recover; - # either way they shouldn't show in any feed view meanwhile. - query = query.where(Video.unavailable_since.is_(None)) - - # Explored videos are PER-EXPLORER private: a via_explore video (ingested when someone - # opened an un-subscribed channel's page) is visible only to users who explored or - # subscribe to its channel, so one user's curiosity never leaks into everyone else's - # catalog. Applied in BOTH scopes; the channel page reaches its ephemeral videos by - # asking for scope=all + channel_ids (the channel is in this accessible set). - accessible_explore = ( - select(Subscription.channel_id).where(Subscription.user_id == user.id) - ).union( - select(ExploredChannel.channel_id).where(ExploredChannel.user_id == user.id) - ) - query = query.where( - or_( - Video.via_explore.is_(False), - Video.channel_id.in_(accessible_explore), - ) - ) - - # Per-user channel blocklist: never show this user videos from a channel they blocked - # (applies to feed, Library, explore, and the channel page alike). - query = query.where( - Video.channel_id.notin_( - select(BlockedChannel.channel_id).where(BlockedChannel.user_id == user.id) - ) - ) - - # Source filter. In the shared Library ("all" scope) it uses the GLOBAL via_search flag - # (search-discovered by anyone). In "my" scope it's the user's OWN provenance: - # "organic" = your subscriptions, "search" = videos you found via your own search, - # "all" = both. Default "organic" keeps the Mine feed = subscriptions only. - if scope == "all": - if library_source == "organic": - query = query.where(Video.via_search.is_(False)) - elif library_source == "search": - query = query.where(Video.via_search.is_(True)) - # "all" → both organic and search-discovered videos - else: - subscribed = Subscription.channel_id.isnot(None) - searched = SearchFind.video_id.isnot(None) - if library_source == "search": - query = query.where(searched) - elif library_source == "all": - query = query.where(or_(subscribed, searched)) - else: # "organic" (default): subscriptions only - query = query.where(subscribed) - - if channel_ids: - # OR across the picked channels; every other filter still applies on top. - query = query.where(Video.channel_id.in_(channel_ids)) - if min_duration is not None: - query = query.where(Video.duration_seconds >= min_duration) - if max_duration is not None: - query = query.where(Video.duration_seconds <= max_duration) - if max_age_days is not None: - cutoff = datetime.now(timezone.utc).timestamp() - max_age_days * 86400 - query = query.where( - Video.published_at >= datetime.fromtimestamp(cutoff, tz=timezone.utc) - ) - if published_after is not None: - start = datetime.combine( - published_after, datetime.min.time(), tzinfo=timezone.utc - ) - query = query.where(Video.published_at >= start) - if published_before is not None: - end = datetime.combine( - published_before, datetime.min.time(), tzinfo=timezone.utc - ) + timedelta(days=1) - query = query.where(Video.published_at < end) - # Full-text relevance search over the weighted search document (title > creator keywords + - # the queries that surfaced the video > description; see Video.search_vector). YouTube-like - # "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed, - # accent-insensitive (unaccent_simple). The channel name still matches as a substring so - # channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort. - rank_expr = None - if q: - ts_str = to_tsquery_str(q) - if ts_str: - tsq = func.to_tsquery("public.unaccent_simple", ts_str) - query = query.where( - or_( - Video.search_vector.op("@@")(tsq), - func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")), - ) - ) - # Scale ts_rank (a float4) to an integer score so the keyset cursor compares it - # EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and - # breaks paging (the same page repeats). 1e6 granularity is ample; ties break on - # published_at then id. - rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger) - else: - # No usable tokens (e.g. only punctuation): fall back to a plain substring match. - like = func.unaccent(f"%{q}%") - query = query.where( - or_( - func.unaccent(Video.title).ilike(like), - func.unaccent(Channel.title).ilike(like), - ) - ) - - if tags: - # AND across tag categories (e.g. language AND topic narrows), OR within a - # category; the any/all toggle controls multiple topic tags. - cat_rows = db.execute(select(Tag.id, Tag.category).where(Tag.id.in_(tags))).all() - by_category: dict[str, list[int]] = {} - for tag_id, category in cat_rows: - by_category.setdefault(category, []).append(tag_id) - # Facet counting drops the category being counted so its own chips don't zero each - # other out (standard drill-down faceting: a category's count ignores its own - # selections but still honours the other categories' filters). - if exclude_tag_category: - by_category.pop(exclude_tag_category, None) - visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id) - for category, ids in by_category.items(): - if category == "topic" and tag_mode == "and" and len(ids) > 1: - sub = ( - select(ChannelTag.channel_id) - .where(ChannelTag.tag_id.in_(ids), visible) - .group_by(ChannelTag.channel_id) - .having(func.count(func.distinct(ChannelTag.tag_id)) == len(set(ids))) - ) - else: - sub = select(ChannelTag.channel_id).where( - ChannelTag.tag_id.in_(ids), visible - ) - query = query.where(Video.channel_id.in_(sub)) - - # Content type: Normal / Shorts / Live·Upcoming as a union of enabled types. - # Explicit Watched/Saved/Hidden views show every type so nothing goes missing. - explicit_view = show in ("watched", "hidden") - if not explicit_view: - type_clauses = [] - if show_normal: - type_clauses.append( - and_(Video.is_short.is_(False), Video.live_status.notin_(LIVE_OR_UPCOMING)) - ) - if include_shorts: - type_clauses.append(Video.is_short.is_(True)) - if include_live: - type_clauses.append(Video.live_status.in_(LIVE_OR_UPCOMING)) - query = query.where(or_(*type_clauses) if type_clauses else false()) - - if show == "unwatched": - query = query.where(status_expr.notin_(("watched", "hidden"))) - elif show == "in_progress": - # Started but not finished: a stored resume position, not yet watched/hidden. - query = query.where( - and_(position_expr > 0, status_expr.notin_(("watched", "hidden"))) - ) - elif show == "watched": - query = query.where(status_expr == "watched") - elif show == "hidden": - query = query.where(status_expr == "hidden") - else: # all - query = query.where(status_expr != "hidden") - - return query, rank_expr - - -def to_tsquery_str(q: str) -> str | None: - """Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word - prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*". - Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in - (the string is built from sanitised tokens, never the raw query).""" - tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)] - if not tokens: - return None - tokens[-1] = tokens[-1] + ":*" - return " & ".join(tokens) - - # Shared query parameters for /feed and /feed/count. def _feed_params( tags: list[int] = Query(default=[]), @@ -392,101 +81,6 @@ def _feed_params( } -# --- Keyset (cursor) pagination --------------------------------------------- -# -# Each sort is described as an ordered list of key columns (expression + direction -# + whether it can be NULL + a JSON round-trip kind), always with Video.id as the -# final, unique tiebreaker. This drives BOTH the ORDER BY and the keyset WHERE so -# they can never drift apart — the prerequisite for correct cursor paging. Keyset -# replaces OFFSET/LIMIT: deep scrolling no longer scans-and-discards skipped rows, -# and the page boundary is stable even as the scheduler ingests new videos mid-scroll -# (OFFSET would shift and duplicate/skip rows). -def _sort_keys(sort: str, seed: int, rank_expr=None) -> list[dict]: - pub = {"expr": Video.published_at, "asc": False, "nullable": True, "kind": "dt"} - table: dict[str, list[dict]] = { - "newest": [pub], - "oldest": [{**pub, "asc": True}], - "views": [{"expr": Video.view_count, "asc": False, "nullable": True, "kind": "num"}], - "views_asc": [{"expr": Video.view_count, "asc": True, "nullable": True, "kind": "num"}], - "duration_desc": [{"expr": Video.duration_seconds, "asc": False, "nullable": True, "kind": "num"}], - "duration_asc": [{"expr": Video.duration_seconds, "asc": True, "nullable": True, "kind": "num"}], - "title": [{"expr": func.lower(Video.title), "asc": True, "nullable": True, "kind": "str"}], - "title_desc": [{"expr": func.lower(Video.title), "asc": False, "nullable": True, "kind": "str"}], - "subscribers": [{"expr": Channel.subscriber_count, "asc": False, "nullable": True, "kind": "num"}], - "subscribers_asc": [{"expr": Channel.subscriber_count, "asc": True, "nullable": True, "kind": "num"}], - # Your per-channel priority (set in the channel manager), newest first within a tier. - # coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row. - "priority": [{"expr": func.coalesce(Subscription.priority, 0), "asc": False, "nullable": False, "kind": "num"}, pub], - "priority_asc": [{"expr": func.coalesce(Subscription.priority, 0), "asc": True, "nullable": False, "kind": "num"}, pub], - # Deterministic per-seed shuffle: the same seed yields the same total order, so - # the cursor stays valid across pages of one shuffled run. - "shuffle": [{"expr": func.md5(func.concat(Video.id, str(seed))), "asc": True, "nullable": False, "kind": "str"}], - } - # Relevance (search-result ranking) only exists when there's a query to rank against; - # without `rank_expr` it falls through to newest. Ties break by newest, then id. - if rank_expr is not None: - table["relevance"] = [ - {"expr": rank_expr, "asc": False, "nullable": False, "kind": "num"}, - pub, - ] - return table.get(sort) or table["newest"] - - -def _order_by(keys: list[dict]): - cols = [] - for k in keys: - c = k["expr"].asc() if k["asc"] else k["expr"].desc() - cols.append(c.nulls_last() if k["nullable"] else c) - cols.append(Video.id.asc()) # unique final tiebreaker - return cols - - -def _keyset_predicate(keys: list[dict], cursor_id: str): - """WHERE clause selecting rows strictly AFTER the cursor in the sort order. - - Lexicographic over the key columns then id, NULL-aware for `nulls_last`: a NULL - sorts after every non-NULL value, so a cursor sitting on a non-NULL value must - also pull in the NULL group, and equality treats NULL == NULL.""" - def eq(k): - return k["expr"].is_(None) if k["value"] is None else k["expr"] == k["value"] - - def after(k): - if k["value"] is None: - return false() # nulls are last; nothing sorts strictly after them - cmp = k["expr"] > k["value"] if k["asc"] else k["expr"] < k["value"] - return or_(cmp, k["expr"].is_(None)) if k["nullable"] else cmp - - branches = [] - for j in range(len(keys)): - branches.append(and_(*[eq(keys[i]) for i in range(j)], after(keys[j]))) - branches.append(and_(*[eq(k) for k in keys], Video.id > cursor_id)) - return or_(*branches) - - -def _encode_cursor(keys: list[dict], row) -> str: - vals = [] - for i, k in enumerate(keys): - v = getattr(row, f"_k{i}") - vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v) - payload = json.dumps({"k": vals, "id": row.id}) - return base64.urlsafe_b64encode(payload.encode()).decode() - - -def _decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]: - try: - payload = json.loads(base64.urlsafe_b64decode(cursor.encode())) - raw = payload["k"] - if len(raw) != len(keys): - raise ValueError("key arity mismatch") - vals = [ - datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v - for k, v in zip(keys, raw) - ] - return vals, payload["id"] - except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError): - raise HTTPException(status_code=400, detail="Invalid feed cursor") - - @router.get("/feed") def get_feed( params: dict = Depends(_feed_params), @@ -497,25 +91,31 @@ def get_feed( user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - query, rank_expr = _filtered_query(db, user, **params) - keys = _sort_keys(sort, seed, rank_expr) + query, rank_expr = filtered_query(db, user, **params) + keys = sort_keys(sort, seed, rank_expr) # Expose each key column on the row so we can build the next cursor from the last item. query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)]) - query = query.order_by(*_order_by(keys)) + query = query.order_by(*order_by(keys)) if cursor: - vals, cursor_id = _decode_cursor(keys, cursor) + # The read-model raises a plain ValueError on a malformed cursor; translate it to the + # HTTP 400 here so feedquery stays web-framework-free. + try: + vals, cursor_id = decode_cursor(keys, cursor) + except ValueError: + # `from None`: the ValueError is an expected bad-input signal, not an error to chain. + raise HTTPException(status_code=400, detail="Invalid feed cursor") from None for k, v in zip(keys, vals): k["value"] = v - query = query.where(_keyset_predicate(keys, cursor_id)) + query = query.where(keyset_predicate(keys, cursor_id)) rows = db.execute(query.limit(limit + 1)).all() has_more = len(rows) > limit page = rows[:limit] return { "items": [serialize(r) for r in page], - "next_cursor": _encode_cursor(keys, page[-1]) if (has_more and page) else None, + "next_cursor": encode_cursor(keys, page[-1]) if (has_more and page) else None, "has_more": has_more, "limit": limit, } @@ -527,7 +127,7 @@ def get_feed_count( user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - query, _rank = _filtered_query(db, user, **params) + query, _rank = filtered_query(db, user, **params) total = db.scalar(select(func.count()).select_from(query.subquery())) return {"count": total or 0} @@ -555,7 +155,7 @@ def get_facets( # keeps them applied, so each remaining chip narrows to channels that ALSO have all # already-selected topics — and tags that can't co-occur drop to zero (hidden). conjunctive = category == "topic" and params.get("tag_mode") == "and" - base, _rank = _filtered_query( + base, _rank = filtered_query( db, user, **{**params, "exclude_tag_category": None if conjunctive else category}, diff --git a/backend/app/routes/playlists.py b/backend/app/routes/playlists.py index 08621ea8..43f0aa1f 100644 --- a/backend/app/routes/playlists.py +++ b/backend/app/routes/playlists.py @@ -9,8 +9,8 @@ from sqlalchemy.orm import Session, aliased from app import quota from app.auth import current_user, has_read_scope, has_write_scope, require_write_scope from app.db import get_db +from app.feedquery import feed_columns, serialize from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState -from app.routes.feed import serialize, feed_columns from app.sync.playlists import ( plan_push, push_playlist, diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index ecd7f8c2..a5ecd2b4 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -8,12 +8,13 @@ from sqlalchemy.orm import Session from app.models import ( PlexItem, ) +from app.plex.lookups import by_rating_key log = logging.getLogger("siftlode.plex") def item_or_404(db: Session, rating_key: str) -> PlexItem: - it = db.query(PlexItem).filter_by(rating_key=str(rating_key)).first() + it = by_rating_key(db, PlexItem, rating_key) if it is None: raise HTTPException(status_code=404, detail="Unknown Plex item") return it diff --git a/backend/app/routes/plex/admin.py b/backend/app/routes/plex/admin.py index 5c5ddee8..abc1b595 100644 --- a/backend/app/routes/plex/admin.py +++ b/backend/app/routes/plex/admin.py @@ -4,10 +4,11 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel +from sqlalchemy import select from sqlalchemy.orm import Session from app import sysconfig -from app.auth import current_user +from app.auth import admin_user, current_user from app.db import get_db from app.models import ( PlexItem, @@ -18,7 +19,6 @@ from app.plex import paths as plex_paths from app.plex import sync as plex_sync from app.plex import watch_sync as plex_watch from app.plex.client import PlexClient, PlexError, PlexNotConfigured -from app.routes.admin import admin_user from app.utils import iso router = APIRouter(prefix="/api/plex", tags=["plex"]) @@ -65,9 +65,9 @@ def _sample_plex_path( """A real Plex-side media file path to validate the mount + path-map against. Prefers the already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled section straight from Plex.""" - row = db.query(PlexItem.file_path).filter(PlexItem.file_path.isnot(None)).first() - if row and row[0]: - return row[0] + fp = db.scalar(select(PlexItem.file_path).where(PlexItem.file_path.isnot(None))) + if fp: + return fp wanted = plex_sync._enabled_section_keys(db) for s in sections: if s.get("type") != "movie": @@ -117,7 +117,9 @@ def watch_link_status( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: """This user's Plex watch-sync link status (drives the Settings toggle).""" - link = db.query(PlexLink).filter_by(user_id=user.id).first() + link = db.execute( + select(PlexLink).where(PlexLink.user_id == user.id) + ).scalars().first() return _link_dict(link) @@ -137,7 +139,9 @@ def watch_link_set( if not sysconfig.get_bool(db, "plex_enabled"): raise HTTPException(status_code=409, detail="The Plex module is disabled.") enabled = body.enabled - link = db.query(PlexLink).filter_by(user_id=user.id).first() + link = db.execute( + select(PlexLink).where(PlexLink.user_id == user.id) + ).scalars().first() if link is None: link = PlexLink( user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc) @@ -158,7 +162,9 @@ def watch_import_now( ) -> dict: """Admin: (re-)run the "Plex is master" import for this account — Plex re-wins on the intersection; Siftlode-only states are preserved. Idempotent.""" - link = db.query(PlexLink).filter_by(user_id=user.id).first() + link = db.execute( + select(PlexLink).where(PlexLink.user_id == user.id) + ).scalars().first() if link is None or not link.sync_enabled: raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.") result = plex_watch.import_owner_watch_state(db, link) diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 9d0efef1..0a33bcb8 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -3,16 +3,28 @@ import threading import time from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel -from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_ +from sqlalchemy import ( + Integer, + String, + and_, + case, + cast, + delete, + func, + literal, + null, + or_, + select, + update, +) from sqlalchemy.orm import Session, aliased from app import sysconfig -from app.auth import current_user +from app.auth import admin_user, current_user from app.db import get_db from app.models import ( PlexCollection, @@ -26,39 +38,56 @@ from app.models import ( PlexState, User, ) +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 sync as plex_sync +from app.plex.cards import episode_card, leaf_card, movie_card, show_card from app.plex.client import PlexClient, PlexError, PlexNotConfigured +from app.plex.lookups import by_rating_key +from app.plex.query import ( + FIELD_COLUMNS, + TS_CONFIG, + LibraryFilters, + apply_meta_filters, + field_recheck, + genres_col, + like_escape, + meta_filter_conds, + split_csv, + title_col, + vector_col, + vector_match, +) from app.routes._common import owned_or_404 -from app.routes.admin import admin_user -from app.routes.feed import to_tsquery_str from app.routes.plex._shared import item_or_404 +from app.utils import to_tsquery_str router = APIRouter(prefix="/api/plex", tags=["plex"]) -_TS_CONFIG = "public.unaccent_simple" - @router.get("/libraries") def list_libraries( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: - """The mirrored libraries the user can browse (drives the Plex scope selector).""" - libs = ( - db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all() - ) + """The mirrored libraries the user can browse (drives the Plex scope selector). HU overlay + libraries are excluded — they're not browsable, they only feed the EN twin's Hungarian metadata.""" + libs = db.execute( + select(PlexLibrary) + .where(PlexLibrary.overlay_of.is_(None)) + .order_by(PlexLibrary.kind.desc(), PlexLibrary.title) + ).scalars().all() out = [] for lib in libs: if lib.kind == "movie": - count = ( - db.query(func.count(PlexItem.id)) - .filter_by(library_id=lib.id, kind="movie") - .scalar() + count = db.scalar( + select(func.count(PlexItem.id)).where( + PlexItem.library_id == lib.id, PlexItem.kind == "movie" + ) ) else: - count = ( - db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar() + count = db.scalar( + select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id) ) out.append( { @@ -71,33 +100,6 @@ def list_libraries( return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out} -def _movie_card(it: PlexItem, st: PlexState | None) -> dict: - return { - "id": it.rating_key, - "type": "movie", - "title": it.title, - "year": it.year, - "duration_seconds": it.duration_s, - "thumb": f"/api/plex/image/{it.rating_key}", - "playable": it.playable, - "status": st.status if st else "new", - "position_seconds": st.position_seconds if st else 0, - } - - -def _show_card(sh: PlexShow, status: str = "new") -> dict: - return { - "id": sh.rating_key, - "type": "show", - "title": sh.title, - "year": sh.year, - "thumb": f"/api/plex/image/{sh.rating_key}", - "season_count": sh.child_count, - # Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge. - "status": status, - } - - def _lib_key(db: Session, library_id: int | None) -> str | None: """The Plex section key for a library id — the info/show page passes it to the admin collection editor (in the unified cross-library view there's no single library prop otherwise).""" @@ -107,139 +109,20 @@ def _lib_key(db: Session, library_id: int | None) -> str | None: return lib.plex_key if lib else None -def _episode_card(e: PlexItem, st: PlexState | None) -> dict: - return { - "id": e.rating_key, - "type": "episode", - "title": e.title, - "summary": e.summary, - "season_number": e.season_number, - "episode_number": e.episode_number, - "duration_seconds": e.duration_s, - "thumb": f"/api/plex/image/{e.rating_key}", - "playable": e.playable, - "status": st.status if st else "new", - "position_seconds": st.position_seconds if st else 0, - } - - -def _leaf_card( - it: PlexItem, - st: PlexState | None, - show_title: str | None = None, - show_rk: str | None = None, -) -> dict: - """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds. - `show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.""" - if it.kind == "episode": - card = _episode_card(it, st) - card["show_title"] = show_title - card["show_id"] = show_rk - return card - return _movie_card(it, st) - - -def _csv(v: str | None) -> list[str]: - return [s.strip() for s in v.split(",") if s.strip()] if v else [] - - -_ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365} - - -def _added_cutoff(within: str | None) -> datetime | None: - days = _ADDED_WINDOWS.get(within or "") - return datetime.now(timezone.utc) - timedelta(days=days) if days else None - - -def _meta_filter_conds(model, p: dict) -> list: - """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for - `p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a - list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets.""" - conds: list = [] - gsel = _csv(p.get("genres")) - if gsel: - gc = [model.genres.contains([g]) for g in gsel] - conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc)) - crs = _csv(p.get("content_ratings")) - if crs: - conds.append(model.content_rating.in_(crs)) - if p.get("year_min") is not None: - conds.append(model.year >= p["year_min"]) - if p.get("year_max") is not None: - conds.append(model.year <= p["year_max"]) - if p.get("rating_min") is not None: - conds.append(model.rating >= p["rating_min"]) - cutoff = _added_cutoff(p.get("added_within")) - if cutoff is not None: - conds.append(model.added_at >= cutoff) - for d in _csv(p.get("directors")): # AND - conds.append(model.directors.contains([d])) - alist = _csv(p.get("actors")) - if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode - ac = [model.cast_names.contains([a]) for a in alist] - conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac)) - stds = _csv(p.get("studios")) - if stds: # OR - conds.append(model.studio.in_(stds)) - if p.get("collection"): - conds.append(model.collection_keys.contains([p["collection"]])) - return conds - - -def _apply_meta_filters(q, model, p: dict): - conds = _meta_filter_conds(model, p) - return q.filter(*conds) if conds else q - - -@dataclass -class LibraryFilters: - """The shared sidebar filter query-params for /library + /facets, declared once and injected via - ``Depends()``. `as_dict()` yields the `p` dict the query builders read; `duration_*` is included - for /facets' wsq but ignored by `_meta_filter_conds` (both callers apply duration separately).""" - - genres: str | None = None - genre_mode: str = "any" - content_ratings: str | None = None - year_min: int | None = None - year_max: int | None = None - rating_min: float | None = None - duration_min: int | None = None - duration_max: int | None = None - added_within: str | None = None - directors: str | None = None - actors: str | None = None - actor_mode: str = ( - "any" # how multiple actors combine: any (OR, default) | all (AND) - ) - studios: str | None = None - collection: str | None = None - - def as_dict(self) -> dict: - return { - "genres": self.genres, - "genre_mode": self.genre_mode, - "content_ratings": self.content_ratings, - "year_min": self.year_min, - "year_max": self.year_max, - "rating_min": self.rating_min, - "duration_min": self.duration_min, - "duration_max": self.duration_max, - "added_within": self.added_within, - "directors": self.directors, - "actors": self.actors, - "actor_mode": self.actor_mode, - "studios": self.studios, - "collection": self.collection, - } +def _norm_lang(lang: str | None) -> str: + """The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN.""" + return "hu" if lang == "hu" else "en" @router.get("/library") def unified_library( scope: str = "both", q: str | None = None, + field: str = "all", sort: str = "added", sort_dir: str = "desc", show: str = "all", + lang: str = "en", offset: int = 0, limit: int = Query(default=40, ge=1, le=100), f: LibraryFilters = Depends(), @@ -251,18 +134,25 @@ def unified_library( its episodes). On a search that also matches episodes (and shows are in scope), the matching episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section).""" offset = max(0, offset) + lang = _norm_lang(lang) p = f.as_dict() - libs = db.query(PlexLibrary).filter_by(enabled=True).all() - movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] - show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] + movie_lib_ids, show_lib_ids = _content_libs(db) want_movies = scope in ("movie", "both") and bool(movie_lib_ids) want_shows = scope in ("show", "both") and bool(show_lib_ids) ts = to_tsquery_str(q) if q else None - tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None + tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None + if field not in FIELD_COLUMNS and field != "all": + field = "all" + # `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for + # the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter. + grid_field = field != "episode" + recheck = ( + field_recheck if tsq is not None and field not in ("all", "episode") else None + ) selects = [] - if want_movies: + if want_movies and grid_field: st = aliased(PlexState) m_status = case( (st.status == "watched", "watched"), @@ -276,10 +166,10 @@ def unified_library( else_="new", ) mq = ( - db.query( + select( PlexItem.rating_key.label("rk"), literal("movie").label("kind"), - PlexItem.title.label("title"), + title_col(PlexItem, lang).label("title"), PlexItem.year.label("year"), PlexItem.rating.label("rating"), PlexItem.added_at.label("added_at"), @@ -290,36 +180,38 @@ def unified_library( func.coalesce(st.position_seconds, 0).label("position_seconds"), m_status.label("status"), ( - func.ts_rank(PlexItem.search_vector, tsq) + func.ts_rank(vector_col(PlexItem, lang), tsq) if tsq is not None else literal(0.0) ).label("rank"), ) .outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) - .filter(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids)) + .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids)) ) if show == "watched": - mq = mq.filter(st.status == "watched") + mq = mq.where(st.status == "watched") elif show == "in_progress": - mq = mq.filter( + mq = mq.where( st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"), ) elif show == "unwatched": - mq = mq.filter( + mq = mq.where( or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) ) else: - mq = mq.filter(or_(st.status.is_(None), st.status != "hidden")) - mq = _apply_meta_filters(mq, PlexItem, p) + mq = mq.where(or_(st.status.is_(None), st.status != "hidden")) + mq = apply_meta_filters(mq, PlexItem, p, lang) if f.duration_min is not None: - mq = mq.filter(PlexItem.duration_s >= f.duration_min) + mq = mq.where(PlexItem.duration_s >= f.duration_min) if f.duration_max is not None: - mq = mq.filter(PlexItem.duration_s <= f.duration_max) + mq = mq.where(PlexItem.duration_s <= f.duration_max) if tsq is not None: - mq = mq.filter(PlexItem.search_vector.op("@@")(tsq)) + mq = mq.where(vector_match(PlexItem, lang, tsq)) + if recheck is not None: + mq = mq.where(recheck(PlexItem, field, tsq, lang)) selects.append(mq) - if want_shows: + if want_shows and grid_field: agg = rollup.agg_subquery(db, user.id).subquery() tot = func.coalesce(agg.c.total, 0) wat = func.coalesce(agg.c.watched, 0) @@ -330,10 +222,10 @@ def unified_library( else_="new", ) sq = ( - db.query( + select( PlexShow.rating_key.label("rk"), literal("show").label("kind"), - PlexShow.title.label("title"), + title_col(PlexShow, lang).label("title"), PlexShow.year.label("year"), PlexShow.rating.label("rating"), PlexShow.added_at.label("added_at"), @@ -344,101 +236,107 @@ def unified_library( literal(0).label("position_seconds"), s_status.label("status"), ( - func.ts_rank(PlexShow.search_vector, tsq) + func.ts_rank(vector_col(PlexShow, lang), tsq) if tsq is not None else literal(0.0) ).label("rank"), ) .outerjoin(agg, agg.c.show_id == PlexShow.id) - .filter(PlexShow.library_id.in_(show_lib_ids)) + .where(PlexShow.library_id.in_(show_lib_ids)) ) if show == "watched": - sq = sq.filter(tot > 0, wat >= tot) + sq = sq.where(tot > 0, wat >= tot) elif show == "in_progress": - sq = sq.filter(or_(wat > 0, inp > 0), wat < tot) + sq = sq.where(or_(wat > 0, inp > 0), wat < tot) elif show == "unwatched": - sq = sq.filter(wat == 0, inp == 0) - sq = _apply_meta_filters(sq, PlexShow, p) + sq = sq.where(wat == 0, inp == 0) + sq = apply_meta_filters(sq, PlexShow, p, lang) if tsq is not None: - sq = sq.filter(PlexShow.search_vector.op("@@")(tsq)) + sq = sq.where(vector_match(PlexShow, lang, tsq)) + if recheck is not None: + sq = sq.where(recheck(PlexShow, field, tsq, lang)) selects.append(sq) - if not selects: - return { - "scope": scope, - "total": 0, - "offset": offset, - "limit": limit, - "items": [], - "episodes": [], - } - union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) - u = union.subquery() - total = db.query(func.count()).select_from(u).scalar() or 0 - - if tsq is not None: - order = [u.c.rank.desc()] - else: - col = { - "title": func.lower(u.c.title), - "year": u.c.year, - "rating": u.c.rating, - "duration": u.c.duration_s, - "release": u.c.rel, - }.get(sort, u.c.added_at) - order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] - order.append(u.c.rk.desc()) - rows = db.query(u).order_by(*order).offset(offset).limit(limit).all() - items = [] - for r in rows: - if r.kind == "movie": - items.append( - { - "id": r.rk, - "type": "movie", - "title": r.title, - "year": r.year, - "duration_seconds": r.duration_s, - "thumb": f"/api/plex/image/{r.rk}", - "playable": r.playable, - "status": r.status, - "position_seconds": r.position_seconds, - } - ) - else: - items.append( - { - "id": r.rk, - "type": "show", - "title": r.title, - "year": r.year, - "thumb": f"/api/plex/image/{r.rk}", - "season_count": r.season_count, - "status": r.status, - } - ) + total = 0 + if selects: + union = ( + selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) + ) + u = union.subquery() + total = db.scalar(select(func.count()).select_from(u)) or 0 + if tsq is not None: + order = [u.c.rank.desc()] + else: + col = { + "title": func.lower(u.c.title), + "year": u.c.year, + "rating": u.c.rating, + "duration": u.c.duration_s, + "release": u.c.rel, + }.get(sort, u.c.added_at) + order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] + order.append(u.c.rk.desc()) + rows = db.execute( + select(u).order_by(*order).offset(offset).limit(limit) + ).all() + + for r in rows: + if r.kind == "movie": + items.append( + { + "id": r.rk, + "type": "movie", + "title": r.title, + "year": r.year, + "duration_seconds": r.duration_s, + "thumb": f"/api/plex/image/{r.rk}", + "playable": r.playable, + "status": r.status, + "position_seconds": r.position_seconds, + } + ) + else: + items.append( + { + "id": r.rk, + "type": "show", + "title": r.title, + "year": r.year, + "thumb": f"/api/plex/image/{r.rk}", + "season_count": r.season_count, + "status": r.status, + } + ) + + # Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour) + # and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes + # target the movie/show grid, so they suppress episodes. episodes = [] - if tsq is not None and want_shows: + if tsq is not None and want_shows and field in ("all", "episode"): ep_st = aliased(PlexState) - eps = ( - db.query(PlexItem, ep_st, PlexShow.title.label("show_title")) + ep_vec = ( + func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, "")) + if field == "episode" + else PlexItem.search_vector + ) + eps = db.execute( + select(PlexItem, ep_st, PlexShow.title.label("show_title")) .outerjoin( ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id) ) .outerjoin(PlexShow, PlexShow.id == PlexItem.show_id) - .filter( + .where( PlexItem.kind == "episode", PlexItem.library_id.in_(show_lib_ids), - PlexItem.search_vector.op("@@")(tsq), + ep_vec.op("@@")(tsq), ) - .order_by(func.ts_rank(PlexItem.search_vector, tsq).desc()) + .order_by(func.ts_rank(ep_vec, tsq).desc()) .limit(24) - .all() - ) + ).all() for e, s, show_title in eps: - card = _episode_card(e, s) + card = episode_card(e, s) card["show_title"] = show_title episodes.append(card) @@ -456,6 +354,7 @@ def unified_library( def facets( scope: str = "both", show: str = "all", + lang: str = "en", f: LibraryFilters = Depends(), user: User = Depends(current_user), db: Session = Depends(get_db), @@ -474,17 +373,12 @@ def facets( "duration_min": None, "duration_max": None, } - libs = db.query(PlexLibrary).filter_by(enabled=True).all() - movie_ids = ( - [lb.id for lb in libs if lb.kind == "movie"] - if scope in ("movie", "both") - else [] - ) - show_ids = ( - [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else [] - ) + all_movie_ids, all_show_ids = _content_libs(db) + movie_ids = all_movie_ids if scope in ("movie", "both") else [] + show_ids = all_show_ids if scope in ("show", "both") else [] if not movie_ids and not show_ids: return empty + lang = _norm_lang(lang) p = f.as_dict() uid = user.id @@ -492,30 +386,30 @@ def facets( def wsq(query, model, ids, pp, kind_movie, with_dur): """Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a facet aggregation query, so the facets reflect exactly the visible result set.""" - query = query.filter(model.library_id.in_(ids), *_meta_filter_conds(model, pp)) + query = query.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang)) if kind_movie: - query = query.filter(model.kind == "movie") + query = query.where(model.kind == "movie") if with_dur and pp.get("duration_min") is not None: - query = query.filter(model.duration_s >= pp["duration_min"]) + query = query.where(model.duration_s >= pp["duration_min"]) if with_dur and pp.get("duration_max") is not None: - query = query.filter(model.duration_s <= pp["duration_max"]) + query = query.where(model.duration_s <= pp["duration_max"]) # Always join watch-state and mirror the grid's status branches (unified_library) exactly — # hidden movies never appear in the grid, so they must not inflate facet counts/bounds either. st = aliased(PlexState) query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid)) if show == "watched": - query = query.filter(st.status == "watched") + query = query.where(st.status == "watched") elif show == "in_progress": - query = query.filter( + query = query.where( st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"), ) elif show == "unwatched": - query = query.filter( + query = query.where( or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) ) else: - query = query.filter(or_(st.status.is_(None), st.status != "hidden")) + query = query.where(or_(st.status.is_(None), st.status != "hidden")) else: if show in ("watched", "in_progress", "unwatched"): agg = rollup.agg_subquery(db, uid).subquery() @@ -524,11 +418,11 @@ def facets( wat = func.coalesce(agg.c.watched, 0) inp = func.coalesce(agg.c.inprog, 0) if show == "watched": - query = query.filter(tot > 0, wat >= tot) + query = query.where(tot > 0, wat >= tot) elif show == "in_progress": - query = query.filter(or_(wat > 0, inp > 0), wat < tot) + query = query.where(or_(wat > 0, inp > 0), wat < tot) else: - query = query.filter(wat == 0, inp == 0) + query = query.where(wat == 0, inp == 0) return query genre_counts: dict[str, int] = {} @@ -551,68 +445,79 @@ def facets( # jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") — # so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the # whole facets endpoint. Mirror the exact guard people.py already applies to the same columns. + # In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)). + gcol = genres_col(model, lang) expanded = ( wsq( - db.query(func.jsonb_array_elements_text(model.genres).label("g")), + select(func.jsonb_array_elements_text(gcol).label("g")), model, ids, genre_pp, kind_movie, True, ) - .filter(func.jsonb_typeof(model.genres) == "array") + .where(func.jsonb_typeof(gcol) == "array") .subquery() ) - for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all(): + for g, c in db.execute( + select(expanded.c.g, func.count()).group_by(expanded.c.g) + ).all(): genre_counts[g] = genre_counts.get(g, 0) + c # Content ratings — exclude its own. crq = ( wsq( - db.query(model.content_rating, func.count()), + select(model.content_rating, func.count()), model, ids, {**p, "content_ratings": None}, kind_movie, True, ) - .filter(model.content_rating.isnot(None)) + .where(model.content_rating.isnot(None)) .group_by(model.content_rating) ) - for cr, c in crq.all(): + for cr, c in db.execute(crq).all(): cr_counts[cr] = cr_counts.get(cr, 0) + c # Year bounds — exclude the year filter. - by = wsq( - db.query(func.min(model.year), func.max(model.year)), - model, - ids, - {**p, "year_min": None, "year_max": None}, - kind_movie, - True, + by = db.execute( + wsq( + select(func.min(model.year), func.max(model.year)), + model, + ids, + {**p, "year_min": None, "year_max": None}, + kind_movie, + True, + ) ).first() if by[0] is not None: years.append(by[0]) if by[1] is not None: years.append(by[1]) - # Rating max — exclude the rating filter. - br = wsq( - db.query(func.max(model.rating)), - model, - ids, - {**p, "rating_min": None}, - kind_movie, - True, - ).scalar() + # Rating max — exclude the rating filter. Coalesce(imdb, audience) to match the score the + # rating filter and the UI badge use (IMDb preferred, audience fallback). + br = db.scalar( + wsq( + select(func.max(func.coalesce(model.imdb_rating, model.rating))), + model, + ids, + {**p, "rating_min": None}, + kind_movie, + True, + ) + ) if br is not None: ratings.append(float(br)) # Duration bounds (movies) — exclude the duration filter. if kind_movie: - bd = wsq( - db.query(func.min(model.duration_s), func.max(model.duration_s)), - model, - ids, - p, - kind_movie, - False, + bd = db.execute( + wsq( + select(func.min(model.duration_s), func.max(model.duration_s)), + model, + ids, + p, + kind_movie, + False, + ) ).first() if bd[0] is not None: durs.append(bd[0]) @@ -625,7 +530,7 @@ def facets( # be absent): the sidebar renders genres solely from this list and hides the whole genre section — # chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to # undo a zero-result selection. Count 0 is fine (genre chips don't display counts). - for g in _csv(f.genres): + for g in split_csv(f.genres): genre_counts.setdefault(g, 0) return { "genres": [ @@ -644,10 +549,6 @@ def facets( } -def _like_escape(s: str) -> str: - return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") - - def _collection_editable(c: PlexCollection) -> bool: """Effective editability: the admin marked it editable AND it's a plain manual collection — never smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex.""" @@ -682,28 +583,39 @@ def collections( the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single library selected).""" if library is not None: - lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() + lib = db.execute( + select(PlexLibrary).where(PlexLibrary.plex_key == str(library)) + ).scalars().first() if lib is None: return {"collections": []} - query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id) + query = select(PlexCollection).where(PlexCollection.library_id == lib.id) else: - enabled_ids = [lb.id for lb in db.query(PlexLibrary.id).filter_by(enabled=True)] + enabled_ids = list( + db.execute( + select(PlexLibrary.id).where( + PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) + ) + ).scalars() + ) if not enabled_ids: return {"collections": []} - query = db.query(PlexCollection).filter( + query = select(PlexCollection).where( PlexCollection.library_id.in_(enabled_ids) ) term = (q or "").strip() if term: - query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%")) - rows = query.order_by( - PlexCollection.child_count.desc().nullslast(), func.lower(PlexCollection.title) - ).all() + query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%")) + rows = db.execute( + query.order_by( + PlexCollection.child_count.desc().nullslast(), + func.lower(PlexCollection.title), + ) + ).scalars().all() return {"collections": [_collection_card(c) for c in rows]} def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: - col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first() + col = by_rating_key(db, PlexCollection, rating_key) if col is None: raise HTTPException(status_code=404, detail="Unknown collection") if not _collection_editable(col): @@ -731,7 +643,9 @@ def create_collection( raise HTTPException( status_code=400, detail="title and item_rating_key are required" ) - lib = db.query(PlexLibrary).filter_by(plex_key=library).first() + lib = db.execute( + select(PlexLibrary).where(PlexLibrary.plex_key == library) + ).scalars().first() if lib is None: raise HTTPException(status_code=404, detail="Unknown library") try: @@ -843,7 +757,7 @@ def collection_set_editable( ) -> dict: """Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections (IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex.""" - col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first() + col = by_rating_key(db, PlexCollection, rating_key) if col is None: raise HTTPException(status_code=404, detail="Unknown collection") want = body.editable @@ -870,11 +784,11 @@ def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict: } -def _playlist_item_query(db: Session, pid: int): +def _playlist_item_query(pid: int): return ( - db.query(PlexPlaylistItem, PlexItem) + select(PlexPlaylistItem, PlexItem) .join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id) - .filter(PlexPlaylistItem.playlist_id == pid) + .where(PlexPlaylistItem.playlist_id == pid) .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) ) @@ -892,42 +806,51 @@ def playlists( group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog.""" contains_id = None if contains: - row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first() - contains_id = row[0] if row else None + contains_id = db.scalar( + select(PlexItem.id).where(PlexItem.rating_key == str(contains)) + ) group_ids: set[int] | None = None if contains_group is not None: rks = [s for s in (contains_group or "").split(",") if s] - rows = db.query(PlexItem.id).filter(PlexItem.rating_key.in_(rks or [""])).all() - group_ids = {r[0] for r in rows} + group_ids = set( + db.execute( + select(PlexItem.id).where(PlexItem.rating_key.in_(rks or [""])) + ).scalars() + ) out = [] - for pl in ( - db.query(PlexPlaylist) - .filter_by(user_id=user.id) + for pl in db.execute( + select(PlexPlaylist) + .where(PlexPlaylist.user_id == user.id) .order_by(PlexPlaylist.updated_at.desc()) - ): + ).scalars(): count = ( - db.query(func.count(PlexPlaylistItem.id)) - .filter_by(playlist_id=pl.id) - .scalar() + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pl.id + ) + ) or 0 ) - first = _playlist_item_query(db, pl.id).first() + first = db.execute(_playlist_item_query(pl.id)).first() card = _playlist_card(pl, count, first[1].rating_key if first else None) if contains_id is not None: card["has_item"] = ( - db.query(PlexPlaylistItem.id) - .filter_by(playlist_id=pl.id, item_id=contains_id) - .first() + db.execute( + select(PlexPlaylistItem.id).where( + PlexPlaylistItem.playlist_id == pl.id, + PlexPlaylistItem.item_id == contains_id, + ) + ).first() is not None ) if group_ids is not None: card["group_in"] = ( - db.query(func.count(PlexPlaylistItem.id)) - .filter( - PlexPlaylistItem.playlist_id == pl.id, - PlexPlaylistItem.item_id.in_(group_ids or [0]), + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pl.id, + PlexPlaylistItem.item_id.in_(group_ids or [0]), + ) ) - .scalar() or 0 ) out.append(card) @@ -958,7 +881,7 @@ def create_playlist( count = 0 thumb_rk = None if seed: - it = db.query(PlexItem).filter_by(rating_key=seed).first() + it = by_rating_key(db, PlexItem, seed) if it is not None: db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0)) count, thumb_rk = 1, it.rating_key @@ -971,23 +894,27 @@ def playlist_detail( pid: int, user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: pl = _own_playlist_or_404(db, pid, user) - rows = _playlist_item_query(db, pid).all() + rows = db.execute(_playlist_item_query(pid)).all() items = [it for (_pi, it) in rows] states = { s.item_id: s - for s in db.query(PlexState).filter( - PlexState.user_id == user.id, - PlexState.item_id.in_([it.id for it in items] or [0]), - ) + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user.id, + PlexState.item_id.in_([it.id for it in items] or [0]), + ) + ).scalars() } shows = { sh.id: sh - for sh in db.query(PlexShow).filter( - PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]) - ) + for sh in db.execute( + select(PlexShow).where( + PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]) + ) + ).scalars() } cards = [ - _leaf_card( + leaf_card( it, states.get(it.id), shows[it.show_id].title if it.show_id in shows else None, @@ -1016,10 +943,14 @@ def rename_playlist( pl.title = title db.commit() count = ( - db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pl.id + ) + ) or 0 ) - first = _playlist_item_query(db, pl.id).first() + first = db.execute(_playlist_item_query(pl.id)).first() return _playlist_card(pl, count, first[1].rating_key if first else None) @@ -1045,17 +976,19 @@ def playlist_add_item( db: Session = Depends(get_db), ) -> dict: pl = _own_playlist_or_404(db, pid, user) - it = db.query(PlexItem).filter_by(rating_key=body.item_rating_key).first() + it = by_rating_key(db, PlexItem, body.item_rating_key) if it is None: raise HTTPException(status_code=404, detail="Unknown item") - exists = ( - db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first() - ) + exists = db.execute( + select(PlexPlaylistItem).where( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id + ) + ).scalars().first() if exists is None: - maxpos = ( - db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) - .filter_by(playlist_id=pid) - .scalar() + maxpos = db.scalar( + select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( + PlexPlaylistItem.playlist_id == pid + ) ) db.add( PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1) @@ -1073,9 +1006,13 @@ def playlist_remove_item( db: Session = Depends(get_db), ) -> dict: pl = _own_playlist_or_404(db, pid, user) - it = db.query(PlexItem).filter_by(rating_key=str(item_rating_key)).first() + it = by_rating_key(db, PlexItem, item_rating_key) if it is not None: - db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).delete() + db.execute( + delete(PlexPlaylistItem).where( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id + ) + ) pl.updated_at = datetime.now(timezone.utc) db.commit() return {"ok": True} @@ -1092,7 +1029,9 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: return [] by_rk = { it.rating_key: it - for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order)) + for it in db.execute( + select(PlexItem).where(PlexItem.rating_key.in_(order)) + ).scalars() } seen: set[str] = set() out: list[PlexItem] = [] @@ -1115,13 +1054,17 @@ def playlist_add_bulk( order is preserved for the newly-added ones. Returns how many were added + the new total.""" pl = _own_playlist_or_404(db, pid, user) items = _items_by_rks(db, body.item_rating_keys) - existing = { - row[0] for row in db.query(PlexPlaylistItem.item_id).filter_by(playlist_id=pid) - } + existing = set( + db.execute( + select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pid) + ).scalars() + ) pos = int( - db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) - .filter_by(playlist_id=pid) - .scalar() + db.scalar( + select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) ) added = 0 for it in items: @@ -1135,7 +1078,11 @@ def playlist_add_bulk( pl.updated_at = datetime.now(timezone.utc) db.commit() total = ( - db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) or 0 ) return {"added": added, "item_count": total} @@ -1154,18 +1101,21 @@ def playlist_remove_bulk( ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)] removed = 0 if ids: - removed = ( - db.query(PlexPlaylistItem) - .filter( + removed = db.execute( + delete(PlexPlaylistItem).where( PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids) - ) - .delete(synchronize_session=False) - ) + ), + execution_options={"synchronize_session": False}, + ).rowcount if removed: pl.updated_at = datetime.now(timezone.utc) db.commit() total = ( - db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) or 0 ) return {"removed": removed, "item_count": total} @@ -1183,13 +1133,19 @@ def reorder_playlist( order = body.item_rating_keys id_by_rk = { it.rating_key: it.id - for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order or [""])) + for it in db.execute( + select(PlexItem).where(PlexItem.rating_key.in_(order or [""])) + ).scalars() } for pos, rk in enumerate(order): iid = id_by_rk.get(rk) if iid is not None: - db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=iid).update( - {"position": pos} + db.execute( + update(PlexPlaylistItem) + .where( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid + ) + .values(position=pos) ) pl.updated_at = datetime.now(timezone.utc) db.commit() @@ -1240,88 +1196,87 @@ def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]: @router.get("/show/{rating_key}") def show_detail( rating_key: str, + lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """A show's seasons + episodes (the drill-down view) with per-user watch state.""" - sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first() + hu = _norm_lang(lang) == "hu" + sh = by_rating_key(db, PlexShow, rating_key) if sh is None: raise HTTPException(status_code=404, detail="Unknown Plex show") - seasons = ( - db.query(PlexSeason) - .filter_by(show_id=sh.id) + seasons = db.execute( + select(PlexSeason) + .where(PlexSeason.show_id == sh.id) .order_by(PlexSeason.season_number.asc().nullsfirst()) - .all() - ) - eps = ( - db.query(PlexItem) - .filter_by(show_id=sh.id, kind="episode") + ).scalars().all() + eps = db.execute( + select(PlexItem) + .where(PlexItem.show_id == sh.id, PlexItem.kind == "episode") .order_by( PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst(), ) - .all() - ) + ).scalars().all() states = { s.item_id: s - for s in db.query(PlexState).filter( - PlexState.user_id == user.id, - PlexState.item_id.in_([e.id for e in eps] or [0]), - ) + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user.id, + PlexState.item_id.in_([e.id for e in eps] or [0]), + ) + ).scalars() } by_season: dict[int | None, list] = {} all_cards: list[ dict ] = [] # every episode, in season/episode order → show-level rollup for e in eps: - card = _episode_card(e, states.get(e.id)) + card = episode_card(e, states.get(e.id)) by_season.setdefault(e.season_id, []).append(card) all_cards.append(card) show_roll = rollup.rollup(all_cards) - # Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). The two live - # Plex calls are cached per-show + fetched in parallel by _show_enrichment; the per-user shaping - # (_related_show_cards adds this user's watch-state) stays out of the shared cache. - rich: dict = {} + # Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill, E5 S5b). The one + # remaining live call is `related` — a Plex recommendation hub we don't mirror — which also carries + # the metadata used to lazily fill a not-yet-enriched show. Any live failure just drops `related` and + # the lazy fill; the row-served detail below still renders (outage-resilient). related: list[dict] = [] try: meta, related_raw = _show_enrichment(db, sh.rating_key) - rich = _rich_meta(meta) - related = _related_show_cards(db, user.id, related_raw, exclude=sh.id) + related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id) + if sh.people_enriched_at is None and meta: + plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta)) + plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views + sh.people_enriched_at = datetime.now(timezone.utc) + try: + db.commit() + except Exception: + db.rollback() except Exception: - # Best-effort enrichment only — the local episode list is already assembled, so any live-Plex - # failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500. + # Best-effort only — the local episode list is already assembled, so any live-Plex failure + # (not configured, HTTP/JSON shape) must degrade, not 500. pass - # Lazy enrichment: persist the show's full cast so its cast members become filterable (same as the - # movie info page — the sync only stored the listing's top ~3), and stamp people_enriched_at so the - # backfill skips it. Best-effort. - if rich.get("cast"): - plex_people.enrich_row_people(db, sh, rich["cast"]) - sh.people_enriched_at = datetime.now(timezone.utc) - try: - db.commit() - except Exception: - db.rollback() - + imdb_id = sh.imdb_id return { "show": { "id": sh.rating_key, - "title": sh.title, - "summary": sh.summary, + "title": (sh.title_hu or sh.title) if hu else sh.title, + "summary": (sh.summary_hu or sh.summary) if hu else sh.summary, "year": sh.year, "thumb": f"/api/plex/image/{sh.rating_key}", "art": f"/api/plex/image/{sh.rating_key}?variant=art", "library": _lib_key(db, sh.library_id), "content_rating": sh.content_rating, "rating": sh.rating, - "genres": sh.genres or [], + "genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [], "studio": sh.studio, "season_count": sh.child_count, - "imdb_rating": rich.get("imdb_rating"), - "imdb_id": rich.get("imdb_id"), - "imdb_url": rich.get("imdb_url"), - "cast": rich.get("cast", []), + "imdb_rating": sh.imdb_rating, + "imdb_id": imdb_id, + "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, + "cast": sh.full_cast or [], # Show-level rollup for the hero action buttons. "status": show_roll["status"], "resume": show_roll["resume"], @@ -1352,7 +1307,7 @@ def _season_block(se: PlexSeason, cards: list[dict]) -> dict: } -def _related_show_cards( +def _relatedshow_cards( db: Session, user_id: int, related: list[dict], exclude: int ) -> list[dict]: """Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable), @@ -1362,9 +1317,11 @@ def _related_show_cards( return [] shows = { s.rating_key: s - for s in db.query(PlexShow).filter( - PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude - ) + for s in db.execute( + select(PlexShow).where( + PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude + ) + ).scalars() } statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()]) out: list[dict] = [] @@ -1374,52 +1331,24 @@ def _related_show_cards( if sh is None or rk in seen: continue seen.add(rk) - out.append(_show_card(sh, statuses.get(sh.id, "new"))) + out.append(show_card(sh, statuses.get(sh.id, "new"))) if len(out) >= 20: break return out -def _rich_meta(meta: dict) -> dict: - """Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content - rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any - missing field is just omitted.""" - # Rating: prefer the explicit IMDb entry in the Rating array, else the generic audienceRating. - imdb_rating = None - for r in meta.get("Rating") or []: - if str(r.get("image") or "").startswith("imdb://"): - try: - imdb_rating = round(float(r.get("value")), 1) - except (TypeError, ValueError): - pass - break - if imdb_rating is None: - try: - imdb_rating = round( - float(meta.get("audienceRating") or meta.get("rating")), 1 - ) - except (TypeError, ValueError): - imdb_rating = None - # IMDb id (tt…) from the Guid list → a clickable link. - imdb_id = None - for g in meta.get("Guid") or []: - gid = str(g.get("id") or "") - if gid.startswith("imdb://"): - imdb_id = gid.split("imdb://", 1)[1] - break - return { - "imdb_rating": imdb_rating, - "imdb_id": imdb_id, - "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, - "content_rating": meta.get("contentRating"), - "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], - "directors": [ - d.get("tag") for d in (meta.get("Director") or []) if d.get("tag") - ][:4], - "studio": meta.get("studio"), - "tagline": meta.get("tagline"), - "cast": plex_people.cast_from_meta(meta), - } +def _content_libs(db: Session) -> tuple[list[int], list[int]]: + """The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single + source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never + browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5).""" + libs = db.execute( + select(PlexLibrary).where( + PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) + ) + ).scalars().all() + movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] + show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] + return movie_lib_ids, show_lib_ids @router.get("/people/cards") @@ -1433,46 +1362,113 @@ def people_cards( movies/shows (in the given scope) feature them. Photos come from the plex_people cache (populated as items are rich-fetched — info-page view + the rich re-sync); counts are live against cast_names. A name with no cached photo yet still returns a card (the UI shows a placeholder).""" - wanted = _csv(names)[:16] + wanted = split_csv(names)[:16] if not wanted: return {"people": []} - libs = db.query(PlexLibrary).filter_by(enabled=True).all() - movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] - show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] + movie_lib_ids, show_lib_ids = _content_libs(db) want_movies = scope in ("movie", "both") and bool(movie_lib_ids) want_shows = scope in ("show", "both") and bool(show_lib_ids) photos = { p.name: p.photo_thumb - for p in db.query(PlexPerson).filter(PlexPerson.name.in_(wanted)) + for p in db.execute( + select(PlexPerson).where(PlexPerson.name.in_(wanted)) + ).scalars() } out = [] for nm in wanted: count = 0 if want_movies: count += ( - db.query(func.count(PlexItem.id)) - .filter( - PlexItem.kind == "movie", - PlexItem.library_id.in_(movie_lib_ids), - PlexItem.cast_names.contains([nm]), + db.scalar( + select(func.count(PlexItem.id)).where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_lib_ids), + PlexItem.cast_names.contains([nm]), + ) ) - .scalar() or 0 ) if want_shows: count += ( - db.query(func.count(PlexShow.id)) - .filter( - PlexShow.library_id.in_(show_lib_ids), - PlexShow.cast_names.contains([nm]), + db.scalar( + select(func.count(PlexShow.id)).where( + PlexShow.library_id.in_(show_lib_ids), + PlexShow.cast_names.contains([nm]), + ) ) - .scalar() or 0 ) out.append({"name": nm, "photo": photos.get(nm), "count": count}) return {"people": out} +# Cap each filmography section — a prolific person shouldn't return an unbounded list the client then +# mounts a card per row for (the grid paginates at PAGE; this page renders all at once). Ample for a +# personal library; the most-recent titles win via the year-desc order. +_PERSON_SECTION_MAX = 200 + + +@router.get("/person/{name}") +def person_detail( + name: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """One cast/crew member's filmography: their cached headshot + the movies and shows (in the enabled + 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)) + movie_lib_ids, show_lib_ids = _content_libs(db) + + movies: list[dict] = [] + if movie_lib_ids: + items = db.execute( + select(PlexItem) + .where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_lib_ids), + or_( + PlexItem.cast_names.contains([name]), + PlexItem.directors.contains([name]), + ), + ) + .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() + } + movies = [movie_card(it, states.get(it.id)) for it in items] + + shows: list[dict] = [] + if show_lib_ids: + show_rows = db.execute( + select(PlexShow) + .where( + PlexShow.library_id.in_(show_lib_ids), + or_( + PlexShow.cast_names.contains([name]), + PlexShow.directors.contains([name]), + ), + ) + .order_by(PlexShow.year.desc().nullslast(), func.lower(PlexShow.title)) + .limit(_PERSON_SECTION_MAX) + ).scalars().all() + 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} + + _STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt") @@ -1493,7 +1489,9 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: return [] cols = { c.rating_key: c - for c in db.query(PlexCollection).filter(PlexCollection.rating_key.in_(keys)) + for c in db.execute( + select(PlexCollection).where(PlexCollection.rating_key.in_(keys)) + ).scalars() } # Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last. ordered = sorted( @@ -1502,30 +1500,31 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: out = [] for col in ordered: rk = col.rating_key - members = ( - db.query(PlexItem) - .filter( + members = db.execute( + select(PlexItem) + .where( PlexItem.library_id == it.library_id, PlexItem.kind == "movie", PlexItem.collection_keys.contains([rk]), ) .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title)) .limit(60) - .all() - ) + ).scalars().all() ids = [m.id for m in members] states = { s.item_id: s - for s in db.query(PlexState).filter( - PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0]) - ) + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0]) + ) + ).scalars() } out.append( { "id": rk, "title": col.title, "source": _collection_source(col), - "items": [_movie_card(m, states.get(m.id)) for m in members], + "items": [movie_card(m, states.get(m.id)) for m in members], } ) return out @@ -1534,16 +1533,16 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]: if it.kind != "episode" or it.show_id is None: return None, None - keys = [ - r[0] - for r in db.query(PlexItem.rating_key) - .filter_by(show_id=it.show_id, kind="episode") - .order_by( - PlexItem.season_number.asc().nullsfirst(), - PlexItem.episode_number.asc().nullsfirst(), - ) - .all() - ] + keys = list( + db.execute( + select(PlexItem.rating_key) + .where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode") + .order_by( + PlexItem.season_number.asc().nullsfirst(), + PlexItem.episode_number.asc().nullsfirst(), + ) + ).scalars() + ) try: i = keys.index(it.rating_key) except ValueError: @@ -1556,22 +1555,30 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]: @router.get("/item/{rating_key}") def item_detail( rating_key: str, + lang: str = "en", user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: """Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode prev/next, and the per-user resume position + watch status.""" + lang = _norm_lang(lang) it = item_or_404(db, rating_key) - st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user.id, PlexState.item_id == it.id + ) + ).scalars().first() - rich: dict = {} + # Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/ + # tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only + # parsed from this live meta for episodes (never enriched — not in the grid, no HU overlay). + meta: dict = {} markers: list[dict] = [] audio_streams: list[dict] = [] subtitle_streams: list[dict] = [] try: with PlexClient(db) as plex: meta = plex.metadata(it.rating_key, markers=True) or {} - rich = _rich_meta(meta) for m in meta.get("Marker") or []: if m.get("type") in ("intro", "credits"): markers.append( @@ -1611,27 +1618,47 @@ def item_detail( } ) except Exception: - pass # metadata extras are best-effort; any live-Plex failure must degrade, not 500 — playback works without them + pass # live extras (markers/streams) are best-effort; any live-Plex failure must degrade, not 500 — playback works without them - # Lazy enrichment: the rich fetch above carries the FULL cast (the sync only stored the listing's - # top ~3), so persist it onto this movie so every cast member shown here becomes filterable, and - # stamp people_enriched_at so the backfill skips it. Movie only — episodes aren't in the grid; a - # show's cast is enriched from /show. Best-effort. - if it.kind == "movie" and rich.get("cast"): - plex_people.enrich_row_people(db, it, rich["cast"]) + # Lazy enrichment (movies only): the live fetch above carries the FULL cast + rich detail (the sync + # only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable + # and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show. + if it.kind == "movie" and it.people_enriched_at is None and meta: + cast = plex_people.cast_from_meta(meta) + if cast: + plex_people.enrich_row_people(db, it, cast) + plex_detail.apply_rich_detail(it, meta) it.people_enriched_at = datetime.now(timezone.utc) try: db.commit() except Exception: db.rollback() + hu = lang == "hu" + # Movies serve rich detail from the DB row; episodes from the live meta just fetched. + if it.kind == "movie": + _imdb_id = it.imdb_id + rich = { + "cast": it.full_cast or [], + "imdb_rating": it.imdb_rating, + "imdb_id": _imdb_id, + "imdb_url": f"https://www.imdb.com/title/{_imdb_id}/" if _imdb_id else None, + "content_rating": it.content_rating, + "genres": (it.genres_hu or it.genres if hu else it.genres) or [], + "directors": it.directors or [], + "studio": it.studio, + "tagline": it.tagline, + } + else: + rich = plex_detail.rich_detail(meta) + prev_id, next_id = _episode_nav(db, it) show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None return { "id": it.rating_key, "kind": it.kind, - "title": it.title, - "summary": it.summary, + "title": (it.title_hu or it.title) if hu else it.title, + "summary": (it.summary_hu or it.summary) if hu else it.summary, "year": it.year, "duration_seconds": it.duration_s, "playable": it.playable, @@ -1642,6 +1669,9 @@ def item_detail( "imdb_rating": rich.get("imdb_rating"), "imdb_id": rich.get("imdb_id"), "imdb_url": rich.get("imdb_url"), + # Audience score (Plex audienceRating) served alongside the IMDb score — the UI shows the + # IMDb badge when imdb_rating is present, else this. Episodes carry it on the row too. + "rating": it.rating, "content_rating": rich.get("content_rating"), "genres": rich.get("genres", []), "directors": rich.get("directors", []), @@ -1652,7 +1682,9 @@ def item_detail( "subtitle_streams": subtitle_streams, "status": st.status if st else "new", "position_seconds": st.position_seconds if st else 0, - "show_title": show.title if show else None, + "show_title": ( + (show.title_hu or show.title if hu else show.title) if show else None + ), "season_number": it.season_number, "episode_number": it.episode_number, "prev_id": prev_id, diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 42495705..58eafb3b 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -16,6 +16,7 @@ from fastapi import ( ) from fastapi.responses import FileResponse from pydantic import BaseModel +from sqlalchemy import select from sqlalchemy.orm import Session from app.auth import current_user, resolved_user_id @@ -29,6 +30,7 @@ from app.models import ( ) from app.plex import imgcache, subtitles from app.plex import paths as plex_paths +from app.plex.lookups import by_rating_key from app.plex import people as plex_people from app.plex import stream as plex_stream from app.plex import watch_sync as plex_watch @@ -207,7 +209,11 @@ def _push_watch( return # Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit # lands before it settles synced_to_plex (which would otherwise strand that newer value). - st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id == item_id + ) + ).scalars().first() background.add_task( plex_watch.push_state_to_plex, user_id, @@ -246,7 +252,11 @@ def item_progress( duration = int(body.duration_seconds) final = body.final now = datetime.now(timezone.utc) - st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user.id, PlexState.item_id == it.id + ) + ).scalars().first() was_watched = st is not None and st.status == "watched" near_end = duration > 0 and position > duration - _FINISH_MARGIN_S @@ -316,7 +326,11 @@ def item_state( status = body.status if status not in ("new", "watched", "hidden"): raise HTTPException(status_code=400, detail="Invalid status") - st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user.id, PlexState.item_id == it.id + ) + ).scalars().first() prev_status = st.status if st is not None else "new" if status == "new": if st is not None: @@ -351,10 +365,12 @@ def _bulk_state( now = datetime.now(timezone.utc) existing = { s.item_id: s - for s in db.query(PlexState).filter( - PlexState.user_id == user_id, - PlexState.item_id.in_([e.id for e in eps] or [0]), - ) + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user_id, + PlexState.item_id.in_([e.id for e in eps] or [0]), + ) + ).scalars() } pushable = plex_watch.link_for_push(db, user_id) is not None to_push: list[tuple[int, str, str]] = [] @@ -397,11 +413,13 @@ def show_state( db: Session = Depends(get_db), ) -> dict: """Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex.""" - sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first() + sh = by_rating_key(db, PlexShow, rating_key) if sh is None: raise HTTPException(status_code=404, detail="Unknown Plex show") watched = body.watched - eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all() + eps = db.execute( + select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode") + ).scalars().all() changed = _bulk_state(background, db, user.id, eps, watched) return {"changed": changed, "watched": watched} @@ -415,11 +433,13 @@ def season_state( db: Session = Depends(get_db), ) -> dict: """Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex.""" - se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first() + se = by_rating_key(db, PlexSeason, rating_key) if se is None: raise HTTPException(status_code=404, detail="Unknown Plex season") watched = body.watched - eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all() + eps = db.execute( + select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode") + ).scalars().all() changed = _bulk_state(background, db, user.id, eps, watched) return {"changed": changed, "watched": watched} diff --git a/backend/app/routes/quota.py b/backend/app/routes/quota.py index 6dd6929e..cc390e23 100644 --- a/backend/app/routes/quota.py +++ b/backend/app/routes/quota.py @@ -10,10 +10,9 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session from app import quota -from app.auth import current_user +from app.auth import admin_user, current_user from app.db import get_db from app.models import QuotaEvent, User -from app.routes.admin import admin_user router = APIRouter(prefix="/api/quota", tags=["quota"]) diff --git a/backend/app/routes/scheduler.py b/backend/app/routes/scheduler.py index df20de62..a52ee7cb 100644 --- a/backend/app/routes/scheduler.py +++ b/backend/app/routes/scheduler.py @@ -8,10 +8,10 @@ from sqlalchemy.orm import Session from app import audit, quota, state, sysconfig from app.audit import AuditAction +from app.auth import admin_user from app.config import settings from app.db import get_db from app.models import Channel, Subscription, User, Video -from app.routes.admin import admin_user from app.scheduler import ( JOB_INTERVALS, MAX_INTERVAL, diff --git a/backend/app/routes/search.py b/backend/app/routes/search.py index 8c860ac3..c0e3d012 100644 --- a/backend/app/routes/search.py +++ b/backend/app/routes/search.py @@ -36,7 +36,7 @@ from app.models import ( Video, VideoState, ) -from app.routes.feed import serialize, feed_columns +from app.feedquery import feed_columns, serialize from app.sync.subscriptions import apply_channel_details from app.sync.videos import insert_stubs, apply_video_details, parse_dt from app.youtube import search_scrape diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index 0c8d66c8..1b2b6eef 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -86,6 +86,7 @@ SPECS: tuple[ConfigSpec, ...] = ( ConfigSpec("plex_server_token", "str", "plex", "plex_server_token", secret=True), ConfigSpec("plex_path_map", "str", "plex", "plex_path_map"), ConfigSpec("plex_libraries", "str", "plex", "plex_libraries"), + ConfigSpec("plex_overlay_map", "str", "plex", "plex_overlay_map"), # (plex_sync_interval_min is tuned from the Scheduler dashboard, not here — it's a scheduler job.) ConfigSpec("plex_max_transcodes", "int", "plex", "plex_max_transcodes", min=0, max=16), # NOTE: plex_watch_sync_interval_min (config.py default) is surfaced here in Phase C, when the diff --git a/backend/app/utils.py b/backend/app/utils.py index d266187c..c6cfb179 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -23,3 +23,16 @@ def valid_email(value: str | None) -> bool: def now_utc() -> datetime: """Current time as a timezone-aware UTC datetime.""" return datetime.now(timezone.utc) + + +def to_tsquery_str(q: str) -> str | None: + """Turn free-text into a to_tsquery() string: word runs ANDed together, the LAST word + prefix-matched (`:*`) for as-you-type search — e.g. "ti amo magyar" → "ti & amo & magyar:*". + Only `\\w` runs containing an alphanumeric are kept, so no tsquery operators can leak in + (the string is built from sanitised tokens, never the raw query). Shared by the YouTube feed + search and the Plex library search, hence this neutral home rather than either domain module.""" + tokens = [t for t in re.findall(r"\w+", q, flags=re.UNICODE) if any(c.isalnum() for c in t)] + if not tokens: + return None + tokens[-1] = tokens[-1] + ":*" + return " & ".join(tokens) diff --git a/backend/tests/test_feed_cursor.py b/backend/tests/test_feed_cursor.py new file mode 100644 index 00000000..0aab9339 --- /dev/null +++ b/backend/tests/test_feed_cursor.py @@ -0,0 +1,45 @@ +"""Feed cursor endpoint: a malformed ?cursor= must be a clean HTTP 400, not a 500. + +Locks the refactor that split cursor handling across modules — feedquery.decode_cursor raises a +plain ValueError and routes/feed.get_feed translates it to 400 — so a future regression (a dropped +try/except, or a new decode_cursor caller without its own catch) turns the 400 back into a 500 and +fails right here. DB lane (`db` fixture) — skipped without Postgres. +""" +from fastapi.testclient import TestClient + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import User + + +def _get(db, user, monkeypatch, path): + monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) + return client.get(path) + finally: + app.dependency_overrides.clear() + + +def test_malformed_feed_cursor_returns_400(db, monkeypatch): + user = User(email="feedcursor@example.com") + db.add(user) + db.commit() + resp = _get(db, user, monkeypatch, "/api/feed?cursor=not-a-real-cursor") + assert resp.status_code == 400 + assert resp.json()["detail"] == "Invalid feed cursor" + + +def test_feed_without_cursor_is_200(db, monkeypatch): + # Sanity: the same endpoint without a cursor is a normal 200 (proves the 400 above is the + # cursor path, not a globally-broken request). A fresh user has no subscriptions → empty feed. + user = User(email="feednocursor@example.com") + db.add(user) + db.commit() + resp = _get(db, user, monkeypatch, "/api/feed") + assert resp.status_code == 200 + assert resp.json()["items"] == [] diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py new file mode 100644 index 00000000..d5d0bba3 --- /dev/null +++ b/backend/tests/test_plex_detail.py @@ -0,0 +1,303 @@ +"""Regression (E5 S5b): the info-page rich detail (IMDb score/id, tagline, full cast) is persisted at +rich-fetch time and served from the DB row instead of a live plex.metadata() call. + +- `rich_detail`/`apply_rich_detail` are pure — parse + persist the rich-only fields. +- `_enrich_people_batch` (the cast backfill) now also stamps those fields. +- `item_detail` serves a MOVIE's detail from the row (survives a live-Plex outage) while keeping + markers/streams live; an EPISODE (never enriched) still parses detail from the live meta. +- `show_detail` serves from the row; `related` stays live best-effort. + +The endpoint tests use the DB lane (`db` fixture) — skipped when no Postgres is reachable. +""" +from types import SimpleNamespace + +from fastapi.testclient import TestClient + +import app.routes.plex.browse as browse +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, User +from app.plex import detail +from app.plex.sync import _apply_facet_fields, _enrich_people_batch + +# A full Plex metadata dict: an explicit IMDb Rating entry (must win over audienceRating), an IMDb Guid, +# a tagline, and a two-person cast (one with a CDN headshot → proxied, one without → thumb None). +RICH_META = { + "Rating": [ + {"image": "imdb://image.rating.imdb", "value": 8.7}, + {"image": "themoviedb://image.rating", "value": 7.0}, + ], + "audienceRating": 9.9, # ignored: the imdb Rating entry is preferred + "Guid": [{"id": "tmdb://238"}, {"id": "imdb://tt0068646"}], + "contentRating": "R", + "Genre": [{"tag": "Crime"}, {"tag": "Drama"}], + "Director": [{"tag": "Francis Ford Coppola"}], + "studio": "Paramount", + "tagline": "An offer you can't refuse.", + "Role": [ + {"tag": "Marlon Brando", "role": "Vito Corleone", "thumb": "https://metadata-static.plex.tv/b.jpg"}, + {"tag": "Al Pacino", "role": "Michael"}, + ], +} + + +# ---- pure parse/persist -------------------------------------------------------------------------- + +def test_rich_detail_prefers_imdb_entry_and_builds_link(): + d = detail.rich_detail(RICH_META) + assert d["imdb_rating"] == 8.7 # the imdb Rating entry, not audienceRating (9.9) + assert d["imdb_id"] == "tt0068646" + assert d["imdb_url"] == "https://www.imdb.com/title/tt0068646/" + assert d["tagline"] == "An offer you can't refuse." + assert [c["name"] for c in d["cast"]] == ["Marlon Brando", "Al Pacino"] + assert d["cast"][0]["role"] == "Vito Corleone" + assert d["cast"][0]["thumb"] and d["cast"][0]["thumb"].startswith("/api/plex/person-image") + assert d["cast"][1]["thumb"] is None # no CDN headshot → not proxied + + +def test_rich_detail_is_imdb_only_no_audience_fallback(): + # E5 S6: imdb_rating is IMDb-ONLY. With no imdb:// Rating entry it's NULL — the Plex audience score + # is NOT mislabelled as an IMDb score (it's served separately via the `rating` column). + d = detail.rich_detail({"audienceRating": 7.25}) + assert d["imdb_rating"] is None + assert d["imdb_id"] is None and d["imdb_url"] is None + + +def test_apply_rich_detail_writes_only_rich_fields(): + row = SimpleNamespace(imdb_rating=None, imdb_id=None, tagline=None, full_cast=None) + detail.apply_rich_detail(row, RICH_META) + assert row.imdb_rating == 8.7 + assert row.imdb_id == "tt0068646" + assert row.tagline == "An offer you can't refuse." + assert [c["name"] for c in row.full_cast] == ["Marlon Brando", "Al Pacino"] + + +def test_apply_rich_detail_empty_cast_persists_null(): + row = SimpleNamespace(imdb_rating=1.0, imdb_id="x", tagline="t", full_cast=[{"name": "stale"}]) + detail.apply_rich_detail(row, {}) # a poor/empty meta must clear to NULL, not keep stale data + assert row.full_cast is None + assert row.imdb_rating is None and row.imdb_id is None and row.tagline is None + + +# ---- backfill persistence (DB lane) -------------------------------------------------------------- + +class _StubClient: + """Stand-in for PlexClient: `_enrich_people_batch` calls plex.metadata(rating_key); item_detail + uses it as a context manager and passes markers=True. `raise_` simulates a Plex outage.""" + + def __init__(self, meta: dict | None = None, raise_: bool = False): + self._meta = meta or {} + self._raise = raise_ + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def metadata(self, rating_key, markers=False): + if self._raise: + raise RuntimeError("plex unavailable") + return self._meta + + +def test_apply_facet_fields_preserves_enriched_cast_names(): + """A full sync must NOT downgrade an already-enriched full cast back to the listing top-20 — since + sync never clears people_enriched_at, nothing would regrow it and the person-page filter would lose + the names past the listing cap (regression guard for E5 S5b's enrich gating).""" + # The cheap listing's top few cast + the (always-present) director. + thin = {"Role": [{"tag": "Al Pacino"}, {"tag": "James Caan"}], "Director": [{"tag": "Francis Ford Coppola"}]} + + # Not-yet-enriched row: the listing cast IS applied. + fresh = SimpleNamespace(people_enriched_at=None, directors=None, cast_names=None) + _apply_facet_fields(fresh, thin) + assert fresh.cast_names == ["Al Pacino", "James Caan"] + + # Enriched row: the fuller stored cast is KEPT, not overwritten by the thin listing. + from datetime import datetime, timezone + enriched = SimpleNamespace( + people_enriched_at=datetime.now(timezone.utc), + directors=["Francis Ford Coppola"], + cast_names=["Marlon Brando", "Al Pacino", "James Caan", "Diane Keaton"], + ) + _apply_facet_fields(enriched, thin) + assert enriched.cast_names == ["Marlon Brando", "Al Pacino", "James Caan", "Diane Keaton"] + # people_text is rebuilt from the (updated) directors + the PRESERVED full cast. + assert "Diane Keaton" in enriched.people_text and "Francis Ford Coppola" in enriched.people_text + + +def test_enrich_people_batch_persists_detail(db): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather") + db.add(mv) + db.commit() + assert mv.people_enriched_at is None and mv.full_cast is None + + n = _enrich_people_batch(db, _StubClient(RICH_META)) + assert n == 1 + db.refresh(mv) + assert mv.imdb_rating == 8.7 + assert mv.imdb_id == "tt0068646" + assert mv.tagline == "An offer you can't refuse." + assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"] + assert mv.cast_names == ["Marlon Brando", "Al Pacino"] # people enrich still runs alongside + assert mv.people_enriched_at is not None # stamped so the backfill skips it next run + + +# ---- item_detail serving (DB lane) --------------------------------------------------------------- + +def _get(db, user, monkeypatch, path): + monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate + 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 _user(db, email): + u = User(email=email) + db.add(u) + db.flush() + return u + + +def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch): + """An enriched movie serves cast/IMDb/tagline from the DB row even when the live Plex call raises — + only markers/streams (which need the live meta) degrade.""" + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + from datetime import datetime, timezone + mv = PlexItem( + rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather", + genres=["Crime", "Drama"], directors=["Francis Ford Coppola"], studio="Paramount", + content_rating="R", imdb_rating=8.7, imdb_id="tt0068646", + tagline="An offer you can't refuse.", + full_cast=[{"name": "Marlon Brando", "role": "Vito Corleone", "thumb": None}], + people_enriched_at=datetime.now(timezone.utc), + ) + db.add(mv) + db.commit() + user = _user(db, "outage@example.com") + db.commit() + + monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(raise_=True)) + resp = _get(db, user, monkeypatch, "/api/plex/item/m1") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["imdb_rating"] == 8.7 + assert body["imdb_id"] == "tt0068646" + assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/" + assert body["tagline"] == "An offer you can't refuse." + assert [c["name"] for c in body["cast"]] == ["Marlon Brando"] + assert body["genres"] == ["Crime", "Drama"] + assert body["markers"] == [] # live fetch failed → markers degrade, but detail survived + + +def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch): + """A not-yet-enriched movie is filled from the live meta on view (and persisted for next time).""" + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + mv = PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="The Godfather") + db.add(mv) + db.commit() + user = _user(db, "lazy@example.com") + db.commit() + + monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(RICH_META)) + resp = _get(db, user, monkeypatch, "/api/plex/item/m1") + assert resp.status_code == 200, resp.text + assert resp.json()["imdb_id"] == "tt0068646" + db.refresh(mv) + assert mv.imdb_id == "tt0068646" # persisted + assert mv.people_enriched_at is not None + assert [c["name"] for c in mv.full_cast] == ["Marlon Brando", "Al Pacino"] + + +def test_item_detail_episode_serves_rich_from_live(db, monkeypatch): + """Episodes are never enriched (not in the grid, no HU overlay), so their detail is parsed from the + live meta the endpoint fetches for markers/streams.""" + lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add(lib) + db.flush() + show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show") + db.add(show) + db.flush() + ep = PlexItem( + rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=show.id + ) + db.add(ep) + db.commit() + user = _user(db, "ep@example.com") + db.commit() + + monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(RICH_META)) + resp = _get(db, user, monkeypatch, "/api/plex/item/e1") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["imdb_id"] == "tt0068646" # from the live meta, not the (NULL) row + assert [c["name"] for c in body["cast"]] == ["Marlon Brando", "Al Pacino"] + db.refresh(ep) + assert ep.full_cast is None # episodes are not persisted/enriched + + +def test_show_detail_serves_from_row(db, monkeypatch): + """A show serves cast/IMDb from the DB row; `related` failing (Plex down) must not drop the detail.""" + lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add(lib) + db.flush() + from datetime import datetime, timezone + show = PlexShow( + rating_key="s1", library_id=lib.id, title="A Show", genres=["Drama"], + imdb_rating=9.1, imdb_id="tt0903747", + full_cast=[{"name": "Bryan Cranston", "role": "Walter White", "thumb": None}], + people_enriched_at=datetime.now(timezone.utc), + ) + db.add(show) + db.commit() + user = _user(db, "show@example.com") + db.commit() + + # _show_enrichment raises → related degrades to []; the row-served detail must still render. + def _boom(db_, rk): + raise RuntimeError("plex unavailable") + + monkeypatch.setattr(browse, "_show_enrichment", _boom) + resp = _get(db, user, monkeypatch, "/api/plex/show/s1") + assert resp.status_code == 200, resp.text + body = resp.json()["show"] + assert body["imdb_rating"] == 9.1 + assert body["imdb_id"] == "tt0903747" + assert body["imdb_url"] == "https://www.imdb.com/title/tt0903747/" + assert [c["name"] for c in body["cast"]] == ["Bryan Cranston"] + assert resp.json()["related"] == [] + + +def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch): + """A not-yet-enriched show is filled from the live meta on first view (imdb/full_cast persisted + + stamped) so subsequent views serve from the row.""" + lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add(lib) + db.flush() + show = PlexShow(rating_key="s1", library_id=lib.id, title="A Show") + db.add(show) + db.commit() + user = _user(db, "showfill@example.com") + db.commit() + + # _show_enrichment returns (live meta, no related) → the fill branch runs. + monkeypatch.setattr(browse, "_show_enrichment", lambda db_, rk: (RICH_META, [])) + resp = _get(db, user, monkeypatch, "/api/plex/show/s1") + assert resp.status_code == 200, resp.text + assert resp.json()["show"]["imdb_id"] == "tt0068646" + db.refresh(show) + assert show.imdb_id == "tt0068646" # persisted for next time + assert show.people_enriched_at is not None + assert [c["name"] for c in show.full_cast] == ["Marlon Brando", "Al Pacino"] diff --git a/backend/tests/test_plex_field_scoped_search.py b/backend/tests/test_plex_field_scoped_search.py new file mode 100644 index 00000000..20da9425 --- /dev/null +++ b/backend/tests/test_plex_field_scoped_search.py @@ -0,0 +1,132 @@ +"""S2 · field-scoped search — the `field` param on /api/plex/library. + +The search box's scope-pill narrows a full-text query to ONE facet of the row: Everywhere (`all`, +the untouched weighted `search_vector`), Titles, Cast & crew (`people`), Descriptions (`summary`), +or Episodes (episode rows only, title-only). These lock that each scope is a strict narrowing of +Everywhere, that `all` is unchanged from the pre-scope behaviour, and that the Episode scope empties +the movie/show grid and only applies where shows are in scope. DB lane (`db` fixture) — skipped +without Postgres (the `search_vector` GIN + per-field `to_tsvector` recheck are real Postgres FTS). +""" +from datetime import datetime, timezone + +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, + PlexSeason, + PlexShow, + User, +) +from fastapi.testclient import TestClient + + +def _dt(day: int) -> datetime: + return datetime(2024, 2, day, tzinfo=timezone.utc) + + +def _seed(db): + """One rare token, `zephyr`, planted in exactly one facet per row so a scope's narrowing is + unambiguous: mt has it ONLY in the title, mp ONLY in cast (people_text), ms ONLY in the summary, + and episode ep ONLY in its title (its show carries none). Everywhere matches all four; each + scoped field matches exactly its one row.""" + movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([movie_lib, show_lib]) + db.flush() + mt = PlexItem(rating_key="mt", library_id=movie_lib.id, kind="movie", + title="Zephyr Rising", people_text="Alice Stone", summary="A calm coastal day.", + year=2001, added_at=_dt(1)) + mp = PlexItem(rating_key="mp", library_id=movie_lib.id, kind="movie", + title="Quiet Harbor", people_text="Zephyr Vance", summary="A calm coastal day.", + year=2002, added_at=_dt(2)) + ms = PlexItem(rating_key="ms", library_id=movie_lib.id, kind="movie", + title="Quiet Harbor Two", people_text="Alice Stone", + summary="A story about zephyr winds.", year=2003, added_at=_dt(3)) + show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Nimbus Show", + summary="Weather anthology.", people_text="Bob Cloud", + year=2004, added_at=_dt(4), child_count=1) + db.add_all([mt, mp, ms, show]) + db.flush() + season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1) + db.add(season) + db.flush() + ep = PlexItem(rating_key="ep", library_id=show_lib.id, kind="episode", title="Zephyr Episode", + show_id=show.id, season_id=season.id, season_number=1, episode_number=1, + added_at=_dt(5)) + db.add(ep) + user = User(email="plexfield@example.com") + db.add(user) + db.commit() + return {"user": 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 _ids(body): + return sorted(i["id"] for i in body["items"]) + + +class TestFieldScopedSearch: + def test_everywhere_matches_every_facet(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() + # title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out + assert _ids(body) == ["mp", "ms", "mt"] + assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] + + def test_field_all_is_identical_to_default(self, db, monkeypatch): + s = _seed(db) + default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json() + scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() + assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"] + assert default["total"] == scoped["total"] + assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]] + + def test_title_scope_narrows_to_title_match(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=title").json() + assert _ids(body) == ["mt"] + assert body["total"] == 1 + assert body["episodes"] == [] # non-episode field scopes suppress the episode section + + def test_people_scope_narrows_to_cast_match(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=people").json() + assert _ids(body) == ["mp"] + + def test_summary_scope_narrows_to_description_match(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=summary").json() + assert _ids(body) == ["ms"] + + def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode").json() + assert body["items"] == [] + assert body["total"] == 0 + assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] + + def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch): + s = _seed(db) + body = _get( + db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode&scope=movie" + ).json() + # movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty + assert body["items"] == [] + assert body["episodes"] == [] + + def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=bogus").json() + assert _ids(body) == ["mp", "ms", "mt"] diff --git a/backend/tests/test_plex_lang.py b/backend/tests/test_plex_lang.py new file mode 100644 index 00000000..98dabe89 --- /dev/null +++ b/backend/tests/test_plex_lang.py @@ -0,0 +1,125 @@ +"""E5 S6: HU-overlay language serving. When ?lang=hu, the unified library, facets, genre filter and +detail endpoints serve the Hungarian `*_hu` columns (falling back to EN where a row has no HU twin); +?lang=en (the default) serves English. Also covers the imdb_rating IMDb-only / audience-score split. +DB lane (`db` fixture) — skipped without Postgres. +""" +from datetime import datetime, 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, User + + +def _seed(db): + """One EN movie library with a single movie that has both EN and HU metadata, plus a second movie + that is EN-only (no HU twin) to prove the coalesce fallback.""" + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + m1 = PlexItem( + rating_key="m1", library_id=lib.id, kind="movie", + title="English Title", title_hu="Magyar Cím", + summary="English summary.", summary_hu="Magyar összefoglaló.", + genres=["Drama"], genres_hu=["Dráma"], + rating=6.5, imdb_rating=None, + year=2001, added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), # serve from row, skip live enrich + ) + m2 = PlexItem( + rating_key="m2", library_id=lib.id, kind="movie", + title="EN Only", genres=["Comedy"], # no HU twin + year=2002, added_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + people_enriched_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ) + db.add_all([m1, m2]) + user = User(email="plexlang@example.com") + db.add(user) + db.commit() + return user + + +def _get(db, user, monkeypatch, path): + monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + client = TestClient(app) # no context manager: skip lifespan + return client.get(path) + finally: + app.dependency_overrides.clear() + + +def _titles(resp): + return {i["title"] for i in resp.json()["items"]} + + +def test_library_serves_active_language_titles(db, monkeypatch): + user = _seed(db) + hu = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu") + en = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en") + # HU serves the twin's Hungarian title; the EN-only row falls back to its English title. + assert _titles(hu) == {"Magyar Cím", "EN Only"} + assert _titles(en) == {"English Title", "EN Only"} + + +def test_facets_vocabulary_is_language_specific(db, monkeypatch): + user = _seed(db) + hu = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=hu") + en = _get(db, user, monkeypatch, "/api/plex/facets?scope=movie&lang=en") + hu_genres = {g["value"] for g in hu.json()["genres"]} + en_genres = {g["value"] for g in en.json()["genres"]} + assert "Dráma" in hu_genres and "Drama" not in hu_genres + assert "Drama" in en_genres and "Dráma" not in en_genres + + +def test_genre_filter_matches_active_language_vocabulary(db, monkeypatch): + user = _seed(db) + # The HU genre chip filters the HU column; the same string against the EN column matches nothing. + hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&genres=Dráma") + miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=en&genres=Dráma") + assert _titles(hit) == {"Magyar Cím"} + assert miss.json()["items"] == [] + + +def test_item_detail_language_and_rating_split(db, monkeypatch): + user = _seed(db) + resp = _get(db, user, monkeypatch, "/api/plex/item/m1?lang=hu") + body = resp.json() + assert body["title"] == "Magyar Cím" + assert body["summary"] == "Magyar összefoglaló." + # imdb_rating is IMDb-only (None here); the audience score is served separately as `rating`. + assert body["imdb_rating"] is None + assert body["rating"] == 6.5 + + +def test_hu_search_still_finds_en_only_titles(db, monkeypatch): + user = _seed(db) + # m2 ("EN Only") has no HU twin, so search_vector_hu carries no title tokens for it. HU-mode search + # must still match it via the EN vector (vector_match's union) — findable by its English title. + resp = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&lang=hu&q=Only") + assert "EN Only" in _titles(resp) + + +def test_rating_filter_prefers_imdb_over_audience(db, monkeypatch): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + # imdb 8.0 but audience 4.0 → a rating_min of 7 must MATCH (filter the displayed best score, + # IMDb preferred), where filtering the raw audience column (4.0) would wrongly exclude it. + db.add(PlexItem( + rating_key="r1", library_id=lib.id, kind="movie", title="High IMDb", + imdb_rating=8.0, rating=4.0, year=2001, + added_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + people_enriched_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + )) + user = User(email="rating@example.com") + db.add(user) + db.commit() + hit = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=7") + assert "High IMDb" in _titles(hit) + miss = _get(db, user, monkeypatch, "/api/plex/library?scope=movie&rating_min=9") + assert "High IMDb" not in _titles(miss) diff --git a/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py new file mode 100644 index 00000000..b49a31c7 --- /dev/null +++ b/backend/tests/test_plex_overlay.py @@ -0,0 +1,223 @@ +"""DB-backed regression (E5 S5a): the HU-overlay sync writes Hungarian metadata onto the matched EN +rows (paired by guid), never creating browsable HU rows, and overlay libraries are excluded from the +browse scope. Also covers the canonical-guid parse and that the generated search_vector_hu populates. + +Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. Runs against the real schema +(migrations 0063/0064) so the JSONB + STORED generated columns are exercised. +""" +from sqlalchemy import func, select + +import app.plex.sync as syncmod +from app import sysconfig +from app.models import PlexItem, PlexLibrary +from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay +from app.routes.plex.browse import _content_libs + + +class _StubPlex: + """Minimal PlexClient stand-in: _sync_overlay only calls section_items via _paginate.""" + + def __init__(self, items: list[dict]): + self._items = items + + def section_items(self, key, item_type, start, size): + return self._items[start : start + size], len(self._items) + + +def _hu_movie(rk: str, title: str, summary: str, genres: list[str], guid: str | None) -> dict: + meta = {"ratingKey": rk, "title": title, "summary": summary, "Genre": [{"tag": g} for g in genres]} + if guid: + meta["Guid"] = [{"id": guid}] + return meta + + +def test_canonical_guid_prefers_tmdb_then_imdb(): + assert _canonical_guid({"Guid": [{"id": "imdb://tt1"}, {"id": "tmdb://55"}]}) == "tmdb:55" + assert _canonical_guid({"Guid": [{"id": "imdb://tt9"}, {"id": "tvdb://7"}]}) == "imdb:tt9" + assert _canonical_guid({"Guid": []}) is None + assert _canonical_guid({}) is None + + +def test_sync_overlay_writes_hu_onto_matched_en_rows(db): + en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie") + hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu") + db.add_all([en_lib, hu_lib]) + db.flush() + hu_lib.overlay_of = en_lib.id + matched = PlexItem( + rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1" + ) + unmatched = PlexItem( + rating_key="m2", library_id=en_lib.id, kind="movie", title="Solo", guid="tmdb:2" + ) + db.add_all([matched, unmatched]) + db.commit() + + stub = _StubPlex( + [ + _hu_movie("h1", "Öldöklés", "Egy rablás terve.", ["Akció", "Krimi"], "tmdb://1"), + _hu_movie("h2", "Ismeretlen", "Nincs EN párja.", ["Dráma"], "tmdb://999"), + ] + ) + stats = {"overlaid": 0} + _sync_overlay(db, stub, hu_lib, en_lib.id, stats) + db.commit() + + db.refresh(matched) + db.refresh(unmatched) + assert matched.title_hu == "Öldöklés" + assert matched.summary_hu == "Egy rablás terve." + assert matched.genres_hu == ["Akció", "Krimi"] + assert matched.search_vector_hu is not None # the STORED generated column populated from title_hu + # The unmatched EN row keeps NULL HU fields (serving falls back to EN). + assert unmatched.title_hu is None + assert stats["overlaid"] == 1 + # No browsable rows were created — the HU items never became plex_items of the overlay library. + hu_row_count = db.scalar( + select(func.count(PlexItem.id)).where(PlexItem.library_id == hu_lib.id) + ) + assert hu_row_count == 0 + # The EN library still holds exactly its two movies (no dup from the overlay pass). + en_row_count = db.scalar( + select(func.count(PlexItem.id)).where(PlexItem.library_id == en_lib.id) + ) + assert en_row_count == 2 + + +class _StubClient: + """Full-sync stand-in: one EN movie section (key 4) + its HU twin (key 9), paired by guid.""" + + def __init__(self, db=None): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def sections(self): + return [ + {"key": "4", "title": "Movies", "type": "movie"}, + {"key": "9", "title": "Filmek", "type": "movie"}, + ] + + def section_items(self, key, item_type, start, size): + if item_type != syncmod._TYPE_MOVIE: + return [], 0 + if key == "4": + items = [{"ratingKey": "en1", "title": "Heat", "Guid": [{"id": "tmdb://1"}]}] + elif key == "9": + items = [ + { + "ratingKey": "hu1", "title": "Öldöklés", "summary": "Rablás.", + "Genre": [{"tag": "Akció"}], "Guid": [{"id": "tmdb://1"}], + } + ] + else: + items = [] + return items[start : start + size], len(items) + + def collections(self, key): + return [] + + def metadata(self, rk, markers=False): + return {} + + +def test_sync_designates_overlay_and_writes_hu(db, monkeypatch): + sysconfig.set_value(db, "plex_enabled", True) + sysconfig.set_value(db, "plex_libraries", "4") # only the EN section is browsable + sysconfig.set_value(db, "plex_overlay_map", "9:4") # HU section 9 overlays EN section 4 + db.commit() + monkeypatch.setattr(syncmod, "PlexClient", _StubClient) + + stats = syncmod.sync(db) + + en = db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == "4")).scalars().one() + hu = db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == "9")).scalars().one() + assert en.metadata_lang == "en" and en.overlay_of is None + assert hu.metadata_lang == "hu" and hu.overlay_of == en.id # pass-2 designation + # The EN movie captured its guid (from includeGuids listing) and received the HU overlay. + m = db.execute(select(PlexItem).where(PlexItem.rating_key == "en1")).scalars().one() + assert m.guid == "tmdb:1" + assert m.title_hu == "Öldöklés" + assert m.genres_hu == ["Akció"] + # The HU section is not browsable and mirrored no rows of its own. + movie_ids, _ = _content_libs(db) + assert en.id in movie_ids and hu.id not in movie_ids + assert db.scalar(select(func.count(PlexItem.id)).where(PlexItem.library_id == hu.id)) == 0 + assert stats["overlaid"] == 1 + + +def test_overlay_reconcile_clears_stale_hu_when_twin_vanishes(db): + """A re-run whose HU section no longer carries a title's guid must drop that EN row's stale HU.""" + en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie") + hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu") + db.add_all([en_lib, hu_lib]) + db.flush() + hu_lib.overlay_of = en_lib.id + row = PlexItem( + rating_key="m1", library_id=en_lib.id, kind="movie", title="Heat", + guid="tmdb:1", title_hu="Öldöklés", summary_hu="régi", genres_hu=["Akció"], + ) + db.add(row) + db.commit() + # The HU section no longer lists this film (empty overlay). + _sync_overlay(db, _StubPlex([]), hu_lib, en_lib.id, {"overlaid": 0}) + db.commit() + db.refresh(row) + assert row.title_hu is None and row.summary_hu is None and row.genres_hu is None + + +def test_apply_item_keeps_prior_guid_when_listing_lacks_guid(): + row = PlexItem(rating_key="m1", kind="movie", guid="tmdb:1") + _apply_item(row, lib_id=7, kind="movie", meta={"title": "Heat"}) # no Guid array + assert row.guid == "tmdb:1" # the prior guid survives a guid-less listing + _apply_item(row, lib_id=7, kind="movie", meta={"title": "Heat", "Guid": [{"id": "tmdb://2"}]}) + assert row.guid == "tmdb:2" # a real guid in the listing still updates it + + +def test_overlay_applies_to_all_editions_sharing_a_guid(db): + en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie") + hu_lib = PlexLibrary(plex_key="9", title="Filmek", kind="movie", metadata_lang="hu") + db.add_all([en_lib, hu_lib]) + db.flush() + hu_lib.overlay_of = en_lib.id + ed1 = PlexItem(rating_key="a", library_id=en_lib.id, kind="movie", title="Heat", guid="tmdb:1") + ed2 = PlexItem(rating_key="b", library_id=en_lib.id, kind="movie", title="Heat (Director's Cut)", guid="tmdb:1") + db.add_all([ed1, ed2]) + db.commit() + + # Two HU entries share the guid — first wins, no double count. + stub = _StubPlex( + [ + _hu_movie("h1", "Öldöklés", "első", ["Akció"], "tmdb://1"), + _hu_movie("h2", "Öldöklés (dup)", "második", ["Krimi"], "tmdb://1"), + ] + ) + stats = {"overlaid": 0} + _sync_overlay(db, stub, hu_lib, en_lib.id, stats) + db.commit() + db.refresh(ed1) + db.refresh(ed2) + assert ed1.title_hu == "Öldöklés" and ed2.title_hu == "Öldöklés" # both editions overlaid + assert ed1.genres_hu == ["Akció"] and ed2.genres_hu == ["Akció"] # first HU entry won + assert stats["overlaid"] == 1 # counted once for the guid, not per edition or per dup HU item + + +def test_content_libs_excludes_overlay_libraries(db): + en_lib = PlexLibrary(plex_key="4", title="Movies", kind="movie") + en_shows = PlexLibrary(plex_key="1", title="TV Shows", kind="show") + db.add_all([en_lib, en_shows]) + db.flush() + overlay = PlexLibrary( + plex_key="9", title="Filmek", kind="movie", metadata_lang="hu", overlay_of=en_lib.id + ) + db.add(overlay) + db.commit() + + movie_ids, show_ids = _content_libs(db) + assert en_lib.id in movie_ids + assert overlay.id not in movie_ids # the HU overlay is never browsable + assert show_ids == [en_shows.id] diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py new file mode 100644 index 00000000..510cfc92 --- /dev/null +++ b/backend/tests/test_plex_person.py @@ -0,0 +1,114 @@ +"""DB-backed regression (E5 S4): GET /api/plex/person/{name} returns a cast/crew member's +filmography — movies + shows in the ENABLED libraries whose cast OR directors include the name, +each carrying the caller's watch-state, plus the cached headshot. Also covers `_content_libs` +scoping (disabled libraries excluded) and the kind filter (episodes are not movies). + +Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable. +""" +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, + PlexShow, + PlexState, + User, +) +from app.routes.plex.browse import _content_libs + +NAME = "Robert De Niro" + + +def _get(db, user, monkeypatch, path): + monkeypatch.setattr(state, "_configured_cache", True) # pass the request gate + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + return TestClient(app).get(path) # no lifespan (skip scheduler/HLS sweep) + finally: + app.dependency_overrides.clear() + + +def test_person_detail_returns_cast_and_director_credits(db, monkeypatch): + movies_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + shows_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + off_lib = PlexLibrary(plex_key="3", title="Off", kind="movie", enabled=False) + db.add_all([movies_lib, shows_lib, off_lib]) + db.flush() + + heat = PlexItem( + rating_key="m1", library_id=movies_lib.id, kind="movie", title="Heat", + year=1995, cast_names=[NAME, "Al Pacino"], + ) + # Director-only credit: the name is NOT in cast_names, only directors → the OR must still catch it. + directed = PlexItem( + rating_key="m2", library_id=movies_lib.id, kind="movie", title="A Bronx Tale", + year=1993, cast_names=["Chazz Palminteri"], directors=[NAME], + ) + # Excluded: a movie in a DISABLED library. + hidden = PlexItem( + rating_key="m3", library_id=off_lib.id, kind="movie", title="Hidden", + year=2000, cast_names=[NAME], + ) + # Excluded: an episode (kind != "movie") that happens to list the name. + episode = PlexItem( + rating_key="e1", library_id=movies_lib.id, kind="episode", title="An Episode", + year=2001, cast_names=[NAME], + ) + show = PlexShow(rating_key="s1", library_id=shows_lib.id, title="A Show", cast_names=[NAME]) + db.add_all([heat, directed, hidden, episode, show]) + db.add(PlexPerson(name=NAME, photo_thumb="/img/deniro.jpg")) + db.flush() + + user = User(email="person@example.com") + db.add(user) + db.flush() + db.add(PlexState(user_id=user.id, item_id=heat.id, status="watched")) + db.commit() + + resp = _get(db, user, monkeypatch, f"/api/plex/person/{NAME.replace(' ', '%20')}") + assert resp.status_code == 200, resp.text + body = resp.json() + + assert body["name"] == NAME + assert body["photo"] == "/img/deniro.jpg" + # Both the cast credit (Heat) and the director-only credit (A Bronx Tale), year desc; NOT the + # disabled-library movie and NOT the episode. + assert [m["title"] for m in body["movies"]] == ["Heat", "A Bronx Tale"] + assert body["movies"][0]["status"] == "watched" # from the caller's PlexState + assert body["movies"][1]["status"] == "new" + assert all(m["type"] == "movie" for m in body["movies"]) + # The show credit. + assert [s["title"] for s in body["shows"]] == ["A Show"] + assert body["shows"][0]["type"] == "show" + + +def test_person_detail_unknown_name_is_empty(db, monkeypatch): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="Solo", cast_names=["Nobody Else"])) + user = User(email="empty@example.com") + db.add(user) + db.commit() + + 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": []} + + +def test_content_libs_partitions_only_enabled(db): + db.add_all([ + PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True), + PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True), + PlexLibrary(plex_key="3", title="OffMovie", kind="movie", enabled=False), + ]) + db.commit() + movie_ids, show_ids = _content_libs(db) + assert len(movie_ids) == 1 and len(show_ids) == 1 # the disabled movie lib is excluded diff --git a/backend/tests/test_plex_query_conversion.py b/backend/tests/test_plex_query_conversion.py new file mode 100644 index 00000000..d11392b9 --- /dev/null +++ b/backend/tests/test_plex_query_conversion.py @@ -0,0 +1,357 @@ +"""S1 characterization net for the Plex-package db.query() -> SQLAlchemy 2.0 select() conversion. + +These lock the OBSERVABLE behaviour of the previously-untested hot query paths — the unified_library +union builder (+ its watch-state branches, ordering, search ranking, episode grouping), the rollup +aggregate, and the sync upsert idempotency — so the mechanical 1.x -> 2.0 conversion can be proven +behaviour-equivalent: the exact same assertions must pass before and after the conversion. DB lane +(`db` fixture) — skipped without Postgres. +""" +from datetime import datetime, timezone + +from fastapi.testclient import TestClient +from sqlalchemy import delete, select + +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, PlexSeason, PlexShow, PlexState, User +from app.plex import rollup, sync + + +def _dt(day: int) -> datetime: + return datetime(2024, 1, day, tzinfo=timezone.utc) + + +def _seed(db): + """A minimal cross-library corpus: 2 movies, 1 show with 2 episodes, and a per-user watch-state + where movie m1 is watched and episode e1 is in-progress (so the show rolls up to in_progress).""" + movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([movie_lib, show_lib]) + db.flush() + m1 = PlexItem(rating_key="m1", library_id=movie_lib.id, kind="movie", + title="Alpha Movie", year=2001, added_at=_dt(1)) + m2 = PlexItem(rating_key="m2", library_id=movie_lib.id, kind="movie", + title="Bravo Movie", year=2002, added_at=_dt(2)) + show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Charlie Show", + year=2003, added_at=_dt(3), child_count=1) + db.add_all([m1, m2, show]) + db.flush() + season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1) + db.add(season) + db.flush() + e1 = PlexItem(rating_key="e1", library_id=show_lib.id, kind="episode", title="Charlie Pilot", + show_id=show.id, season_id=season.id, season_number=1, episode_number=1, added_at=_dt(4)) + e2 = PlexItem(rating_key="e2", library_id=show_lib.id, kind="episode", title="Charlie Finale", + show_id=show.id, season_id=season.id, season_number=1, episode_number=2, added_at=_dt(5)) + db.add_all([e1, e2]) + user = User(email="plexbrowse@example.com") + db.add(user) + db.flush() + db.add_all([ + PlexState(user_id=user.id, item_id=m1.id, status="watched"), + PlexState(user_id=user.id, item_id=e1.id, position_seconds=120), + ]) + db.commit() + return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show} + + +def _request(db, user, monkeypatch, method, path, json=None): + monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) + return client.request(method, path, json=json) + finally: + app.dependency_overrides.clear() + + +def _get(db, user, monkeypatch, path): + return _request(db, user, monkeypatch, "GET", path) + + +class TestUnifiedLibrary: + def test_both_scope_unions_movies_and_shows_ordered_by_added(self, db, monkeypatch): + s = _seed(db) + r = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=both") + assert r.status_code == 200, r.text + body = r.json() + assert body["total"] == 3 # 2 movies + 1 show, unioned + # default sort = added_at desc: show (day 3) > bravo (day 2) > alpha (day 1) + assert [i["id"] for i in body["items"]] == ["s1", "m2", "m1"] + show_item = next(i for i in body["items"] if i["type"] == "show") + assert show_item["status"] == "in_progress" # e1 in-progress -> show rollup + assert show_item["season_count"] == 1 + assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched" + + def test_scope_movie_only(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=movie").json() + assert body["total"] == 2 + assert all(i["type"] == "movie" for i in body["items"]) + + def test_scope_show_only(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=show").json() + assert [i["id"] for i in body["items"]] == ["s1"] + assert body["items"][0]["season_count"] == 1 + + def test_watch_state_branches(self, db, monkeypatch): + s = _seed(db) + # watched: only movie m1 (the show is in_progress, not fully watched) + watched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=watched").json() + assert [i["id"] for i in watched["items"]] == ["m1"] + # in_progress: only the show (no movie is in progress) + inprog = _get(db, s["user"], monkeypatch, "/api/plex/library?show=in_progress").json() + assert [i["id"] for i in inprog["items"]] == ["s1"] + # unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means + # 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.) + unwatched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=unwatched").json() + assert [i["id"] for i in unwatched["items"]] == ["m2"] + + def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=charlie").json() + # only the show title matches in the item grid; the movies don't contain "charlie" + assert [i["id"] for i in body["items"]] == ["s1"] + assert body["total"] == 1 + # matching episodes come back grouped in the separate episodes list + assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"] + + def test_pagination_caps_items_not_total(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?limit=2&offset=0").json() + assert body["total"] == 3 + assert [i["id"] for i in body["items"]] == ["s1", "m2"] # first page of the added-desc order + + +class TestRollup: + def test_state_map_new_inprogress_watched(self, db): + s = _seed(db) + show, user = s["show"], s["user"] + # e1 in-progress, e2 new -> in_progress + assert rollup.state_map(db, user.id, [show.id]) == {show.id: "in_progress"} + # empty input short-circuits + assert rollup.state_map(db, user.id, []) == {} + # a fresh user with no state -> new + other = User(email="rollup-other@example.com") + db.add(other) + db.commit() + assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"} + # mark every episode watched for `user` -> watched + db.execute(delete(PlexState).where(PlexState.user_id == user.id)) + for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars(): + db.add(PlexState(user_id=user.id, item_id=e.id, status="watched")) + db.commit() + assert rollup.state_map(db, user.id, [show.id]) == {show.id: "watched"} + + +def _fake_paginate(by_type): + def _p(_plex, _key, typ): + return list(by_type.get(typ, [])) + return _p + + +class TestSyncUpsertIdempotency: + def test_movies_upsert_in_place_no_duplicates(self, db, monkeypatch): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.commit() + metas = [ + {"ratingKey": "100", "title": "First", "year": 2000, "addedAt": 1700000000}, + {"ratingKey": "101", "title": "Second", "year": 2001, "addedAt": 1700000001}, + ] + monkeypatch.setattr(sync, "_paginate", _fake_paginate({sync._TYPE_MOVIE: metas})) + sync._sync_movies(db, object(), lib, {"movies": 0}) + db.commit() + # second run with one title changed: same rows, updated in place (upsert, not re-insert) + metas[0]["title"] = "First Renamed" + sync._sync_movies(db, object(), lib, {"movies": 0}) + db.commit() + rows = db.execute( + select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie") + ).scalars().all() + assert {r.rating_key for r in rows} == {"100", "101"} # no duplicate rows + assert next(r for r in rows if r.rating_key == "100").title == "First Renamed" + + def test_shows_upsert_links_episodes_and_is_idempotent(self, db, monkeypatch): + lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add(lib) + db.commit() + by_type = { + sync._TYPE_SHOW: [ + {"ratingKey": "s1", "title": "Show One", "childCount": 1, "addedAt": 1700000000} + ], + sync._TYPE_SEASON: [ + {"ratingKey": "se1", "parentRatingKey": "s1", "title": "Season 1", "index": 1} + ], + sync._TYPE_EPISODE: [ + {"ratingKey": "e1", "title": "Ep One", "grandparentRatingKey": "s1", + "parentRatingKey": "se1", "parentIndex": 1, "index": 1, "addedAt": 1700000001} + ], + } + monkeypatch.setattr(sync, "_paginate", _fake_paginate(by_type)) + for _ in range(2): # run twice — must be idempotent + sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0}) + db.commit() + shows = db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars().all() + assert {sh.rating_key for sh in shows} == {"s1"} # no duplicate show + ep = db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars().one() + assert ep.rating_key == "e1" + assert ep.show_id == shows[0].id # episode linked to its show + assert (ep.season_number, ep.episode_number) == (1, 1) + + +class TestFacetsWithData: + """The facets endpoint's wsq builder is the most complex converted query (genre/content-rating + JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only + covers the null-genre guard; this locks the aggregated numbers with real data.""" + + def test_facet_aggregation(self, db, monkeypatch): + movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([movie_lib, show_lib]) + db.flush() + db.add_all([ + PlexItem(rating_key="m1", library_id=movie_lib.id, kind="movie", title="M1", + genres=["Comedy", "Drama"], content_rating="PG", year=2000, rating=7.5, + duration_s=6000), + PlexItem(rating_key="m2", library_id=movie_lib.id, kind="movie", title="M2", + genres=["Comedy"], content_rating="R", year=2010, rating=8.0, duration_s=7200), + PlexShow(rating_key="s1", library_id=show_lib.id, title="S1", + genres=["Drama"], content_rating="TV-14", year=2005, rating=9.0), + ]) + user = User(email="facetdata@example.com") + db.add(user) + db.commit() + + body = _get(db, user, monkeypatch, "/api/plex/facets?scope=both").json() + assert {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2} + assert {c["value"]: c["count"] for c in body["content_ratings"]} == { + "PG": 1, "R": 1, "TV-14": 1 + } + assert (body["year_min"], body["year_max"]) == (2000, 2010) + assert body["rating_max"] == 9.0 + # Duration bounds are movie-only. + assert (body["duration_min"], body["duration_max"]) == (6000, 7200) + + +class TestPlaylistAndCollectionEndpoints: + """Smoke coverage for the converted CRUD endpoints that had no tests: playlist create/list/detail/ + add/reorder/remove/delete, and the collections list. A 1.x->2.0 conversion slip in any of these + (a lost .scalars(), a wrong .all(), a broken order_by) surfaces here instead of shipping green.""" + + def _movie(self, db, lib_id, rk, title): + return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title) + + def test_playlist_crud_roundtrip(self, db, monkeypatch): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([self._movie(db, lib.id, "m1", "First"), self._movie(db, lib.id, "m2", "Second")]) + user = User(email="pl@example.com") + db.add(user) + db.commit() + + # create (seeded with m1) -> item_count 1 + created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", + {"title": "Faves", "item_rating_key": "m1"}) + assert created.status_code == 200, created.text + pid = created.json()["id"] + assert created.json()["item_count"] == 1 + + # list -> the playlist with its count + listed = _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] + assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)] + + # add m2, then reorder to [m2, m1] + add = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items", + {"item_rating_key": "m2"}) + assert add.status_code == 200, add.text + _request(db, user, monkeypatch, "PUT", f"/api/plex/playlists/{pid}/order", + {"item_rating_keys": ["m2", "m1"]}) + detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() + assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order + + # remove m1 -> one item left + rm = _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}/items/m1") + assert rm.status_code == 200, rm.text + after = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() + assert [it["id"] for it in after["items"]] == ["m2"] + + # delete the playlist -> gone from the list + _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}") + assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == [] + + def test_playlist_bulk_add_and_remove(self, db, monkeypatch): + # Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's + # synchronize_session=False path, which the single add/remove roundtrip never exercises. + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([self._movie(db, lib.id, rk, rk.upper()) for rk in ("m1", "m2", "m3")]) + user = User(email="bulk@example.com") + db.add(user) + db.commit() + + created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", {"title": "Bulk"}) + pid = created.json()["id"] + + added = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items/bulk", + {"item_rating_keys": ["m1", "m2", "m3"]}) + assert added.status_code == 200, added.text + assert added.json() == {"added": 3, "item_count": 3} + + removed = _request(db, user, monkeypatch, "POST", + f"/api/plex/playlists/{pid}/items/remove-bulk", + {"item_rating_keys": ["m2"]}) + assert removed.status_code == 200, removed.text + assert removed.json() == {"removed": 1, "item_count": 2} + + detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() + assert [it["id"] for it in detail["items"]] == ["m1", "m3"] # input order preserved, m2 gone + + def test_collections_list(self, db, monkeypatch): + from app.models import PlexCollection + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([ + PlexCollection(rating_key="c1", library_id=lib.id, title="Marvel", child_count=12), + PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=5), + ]) + user = User(email="col@example.com") + db.add(user) + db.commit() + + # unified scope (no library param) -> most-populated first + body = _get(db, user, monkeypatch, "/api/plex/collections").json() + assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"] + # name filter + filtered = _get(db, user, monkeypatch, "/api/plex/collections?q=pix").json() + assert [c["title"] for c in filtered["collections"]] == ["Pixar"] + + def test_item_state_watched_then_new(self, db, monkeypatch): + # No PlexLink -> the background Plex push is a no-op; only the local PlexState path runs, which + # is what the conversion touched (the st lookup + the delete on ->new). + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1")) + user = User(email="state@example.com") + db.add(user) + db.commit() + + watched = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", + {"status": "watched"}) + assert watched.status_code == 200, watched.text + assert watched.json()["status"] == "watched" + row = db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().one() + assert row.status == "watched" + + # -> new deletes the state row + back = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "new"}) + assert back.status_code == 200, back.text + assert db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first() is None diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0475ee4b..c55b675c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -351,7 +351,13 @@ export default function App() { /> )} -
+ {/* `@container/appmain`: the floating header + its search box adapt to THIS column's width + (which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint + can't see the sidebar-driven narrowing (E5 S7). NB: `container-type: inline-size` implies + `contain: layout`, making this column a containing block for `position: fixed` descendants + too — safe today because floating-ui menus render via FloatingPortal (to document.body) and + the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */} +
{channelView ? ( diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 14de9e6c..fd8e822b 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,29 +1,33 @@ -import { type ReactNode } from "react"; +import { forwardRef, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Youtube } from "lucide-react"; import type { Me } from "../lib/api"; import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules"; import ModuleName from "./ModuleName"; import SearchBar from "./SearchBar"; +import PlexSearchBox from "./PlexSearchBox"; +import PlexScopeToggle from "./PlexScopeToggle"; import SyncStatus from "./SyncStatus"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; -import { usePlex, usePlexActions } from "./PlexProvider"; import { useChannels, useChannelsActions } from "./ChannelsProvider"; // The floating top header: a row of glass pills over the content — a ModuleName pill (label + // accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user // sync chip at the right end. -export default function Header({ - me, - onYtSearch, -}: { - me: Me; - // Trigger a live YouTube search for the current term (feed only; hidden for the demo account, - // which can't spend the shared quota). - onYtSearch: (q: string) => void; -}) { +// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly +// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which +// owns both the header slot and the padded content wrapper (E5 S7). +const Header = forwardRef< + HTMLDivElement, + { + me: Me; + // Trigger a live YouTube search for the current term (feed only; hidden for the demo account, + // which can't spend the shared quota). + onYtSearch: (q: string) => void; + } +>(function Header({ me, onYtSearch }, ref) { const { t } = useTranslation(); const { page } = useNavigation(); const { setPage } = useNavigationActions(); @@ -34,8 +38,6 @@ export default function Header({ // "go to full history" jump is a ChannelsProvider action. const { search: playlistsSearch } = usePlaylists(); const { setSearch: setPlaylistsSearch } = usePlaylistsActions(); - const { q: plexQ } = usePlex(); - const { setQ: setPlexQ } = usePlexActions(); const { search: channelsSearch } = useChannels(); const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions(); // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so @@ -70,14 +72,7 @@ export default function Header({ /> ); } else if (page === "plex" && me.plex_enabled) { - search = ( - - ); + search = ; } else if (page === "channels") { search = ( +
{search} + {/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from + the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */} + {page === "plex" && me.plex_enabled && } {/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other pills rather than pinned to the far right: on a wide window that just strands it. Only on the two modules it says anything about: the feed those subscriptions fill, and the manager @@ -121,4 +122,6 @@ export default function Header({ )}
); -} +}); + +export default Header; diff --git a/frontend/src/components/PageShell.dom.test.tsx b/frontend/src/components/PageShell.dom.test.tsx new file mode 100644 index 00000000..8b526bc4 --- /dev/null +++ b/frontend/src/components/PageShell.dom.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +// +// E5 S7: PageShell measures the floating header and publishes its real height as `--hdr-h` onto the +// content wrapper, so a header that flex-wraps to 2–3 rows on a narrow window never overlaps the +// content below it. jsdom does no layout (offsetHeight is 0) and has no ResizeObserver, so we stub +// both: a ResizeObserver that hands us its callback, and offset metrics on the measured header. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import PageShell from "./PageShell"; + +vi.mock("./PageScroller", () => ({ + default: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +let roCb: (() => void) | null = null; +beforeEach(() => { + roCb = null; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(cb: () => void) { + roCb = cb; + } + observe() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("PageShell --hdr-h (E5 S7)", () => { + it("publishes the measured header height onto the content wrapper", () => { + const { container } = render( + H
}>content, + ); + const content = container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement; + expect(content).toBeTruthy(); + // Give the header a real (multi-row) box, then fire the ResizeObserver callback the effect made. + const hdr = container.querySelector('[data-testid="hdr"]') as HTMLElement; + Object.defineProperty(hdr, "offsetTop", { value: 12, configurable: true }); + Object.defineProperty(hdr, "offsetHeight", { value: 96, configurable: true }); + roCb?.(); + expect(content.style.getPropertyValue("--hdr-h")).toBe("116px"); // 12 + 96 + 8 + }); + + it("adds no `--hdr-h` pad when there is no header", () => { + const { container } = render(content); + expect(container.querySelector('[class*="pt-(--hdr-h)"]')).toBeNull(); + }); +}); diff --git a/frontend/src/components/PageShell.tsx b/frontend/src/components/PageShell.tsx index a530eccd..98d302a7 100644 --- a/frontend/src/components/PageShell.tsx +++ b/frontend/src/components/PageShell.tsx @@ -1,4 +1,15 @@ -import { createContext, useContext, useState, type ReactNode } from "react"; +import { + cloneElement, + createContext, + isValidElement, + useContext, + useLayoutEffect, + useRef, + useState, + type ReactElement, + type ReactNode, + type Ref, +} from "react"; import { createPortal } from "react-dom"; import PageScroller from "./PageScroller"; @@ -52,10 +63,39 @@ export default function PageShell({ fadeTop?: boolean; }) { const [toolbarSlot, setToolbarSlot] = useState(null); + // The floating header pads the content below it by `--hdr-h`. That must equal the header's REAL + // height, which grows as the flex-wrap header wraps to 2–3 rows on a narrow window — a fixed pad + // would let the content toolbar slide up under it (E5 S7). Measure the header (a ref forwarded onto + // its root box) and write its height straight onto the content wrapper that consumes `--hdr-h`, so + // the two live together with no assumption about the header's DOM parent. + const headerRef = useRef(null); + const contentRef = useRef(null); + useLayoutEffect(() => { + const h = headerRef.current; + const c = contentRef.current; + if (!h || !c) return; + // `+ 8` = the header's own gap-y-2 row gap (0.5rem), reused as the gap below it. + const apply = () => c.style.setProperty("--hdr-h", `${h.offsetTop + h.offsetHeight + 8}px`); + apply(); + const ro = new ResizeObserver(apply); + ro.observe(h); + return () => { + ro.disconnect(); + c.style.removeProperty("--hdr-h"); + }; + }, []); + const measuredHeader = isValidElement(header) + ? cloneElement(header as ReactElement<{ ref?: Ref }>, { + ref: headerRef, + }) + : header; return ( - {header} -
+ {measuredHeader} +
{banners}
diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 83566f46..7c8440fc 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -9,9 +9,14 @@ import { type ReactNode, } from "react"; import { qk } from "../lib/queryKeys"; -import { healPlexSort } from "../lib/plexSort"; +import { healPlexField } from "../lib/plexSearch"; +import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; +import SortControl from "./SortControl"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import { plexLang } from "../i18n"; +import { pickRatingBadge } from "../lib/plexRating"; import { useInfiniteQuery, useQuery, @@ -42,6 +47,7 @@ import { type PlexCard, type PlexCastMember, type PlexFilters, + type PlexPersonDetail, type PlexSeasonDetail, type PlexUnifiedResult, } from "../lib/api"; @@ -74,6 +80,7 @@ type Sub = | { kind: "season"; showId: string; seasonId: string } | { kind: "player"; id: string; queue?: string[] } | { kind: "info"; id: string } + | { kind: "person"; name: string } | { kind: "playlist"; id: number }; // The Plex module's content area: browse/search the mirrored library as poster cards, drill from a @@ -84,15 +91,40 @@ type Sub = const PAGE = 40; +// Persist a watch-state toggle from any Plex surface: flip via the caller's `optimistic` cache +// updater, call the API, surface a failure (the click used to look dead on 403/404/429), then +// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the +// browse grid and the person page, each of which owns a different cache shape. +async function toggleWatchState( + card: PlexCard, + t: TFunction, + handlers: { optimistic: (next: string) => void; resync: () => void }, +) { + const next = card.status === "watched" ? "new" : "watched"; + handlers.optimistic(next); + try { + await api.plexSetState(card.id, next); + } catch (e) { + notifyActionError(e, t("plex.watchedFailed")); + } + handlers.resync(); +} + export default function PlexBrowse() { const { t } = useTranslation(); const qc = useQueryClient(); // Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the // search term (q) comes from the shared header box; the rail hands off a playlist to open via the // shared `playlistOpen` slot, which we consume into a history subview then clear. - const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex(); - const { setScope, setFilters, setPlaylistOpen } = usePlexActions(); + const { q, scope, show, sort, filters, field, playlistOpen: openPlaylist, jumpTo } = usePlex(); + const { setScope, setSort, setFilters, setPlaylistOpen, setJumpTo } = usePlexActions(); const dq = useDebounced(q.trim(), 350); + // Active language for the HU overlay: "hu" serves Hungarian metadata, else EN. In the query keys so + // a switch refetches the grid/facets/detail; useTranslation re-renders this component on switch. + const lang = plexLang(); + // The language the grid query is allowed to run at — lags `lang` by one render on a switch so the + // genre-clear effect (below) can settle first (see the effect for why). + const [committedLang, setCommittedLang] = useState(lang); // Plex's active-filter chip row (above the grid), the shared feed idiom: one removable chip per // narrowing, plus a Clear-all (shared with the sidebar) that resets filters + watch-state + sort. const plexChips = usePlexActiveFilters(); @@ -100,6 +132,9 @@ export default function PlexBrowse() { // The provider holds sort scope-agnostically; heal it against THIS scope's options so a movie-only // key (e.g. duration) left over from before a switch to shows doesn't ask for an unavailable sort. const sortState = healPlexSort(sort, scope); + const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); + // Same idiom for the search scope-pill: a movie-only scope can't hold the Episode field. + const fieldState = healPlexField(field, scope); const sub = useHistorySubview({ kind: "grid" }); // Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then @@ -112,6 +147,19 @@ export default function PlexBrowse() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [openPlaylist]); + // The header search typeahead picked a title → open it the same way a card click would (a movie's + // info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open. + useEffect(() => { + if (jumpTo != null) { + if (jumpTo.type !== "show") infoScrollRef.current = 0; // a jumped-to info page starts at the top + sub.open( + jumpTo.type === "show" ? { kind: "show", id: jumpTo.id } : { kind: "info", id: jumpTo.id }, + ); + setJumpTo(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jumpTo]); + // 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 @@ -141,7 +189,7 @@ export default function PlexBrowse() { const tag = (el?.tagName || "").toLowerCase(); if (tag === "input" || tag === "textarea" || tag === "select" || el?.isContentEditable) return; - if (["show", "season", "info", "playlist"].includes(sub.view.kind)) { + if (["show", "season", "info", "person", "playlist"].includes(sub.view.kind)) { e.preventDefault(); sub.back(); } @@ -151,15 +199,19 @@ export default function PlexBrowse() { }, [sub]); const browseQ = useInfiniteQuery({ - queryKey: qk.plexLibrary(scope, dq, sortState, show, filters), - enabled: !!scope && sub.view.kind === "grid", + queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState, lang), + // Gate on committedLang so a language switch fires ONE fetch (after the genre-clear effect + // settles), not a throwaway one with stale-language genres followed by a second. + enabled: !!scope && sub.view.kind === "grid" && committedLang === lang, initialPageParam: 0, queryFn: ({ pageParam }) => api.plexLibrary({ scope, q: dq || undefined, + field: fieldState, sort: sortState, show, + lang, filters, offset: pageParam as number, limit: PAGE, @@ -170,6 +222,18 @@ export default function PlexBrowse() { }, }); + // Genre chips are language-specific strings (the HU column stores "Dráma", the EN one "Drama"), so a + // selection made in one language matches nothing against the other's column and would silently empty + // the grid. On a language flip, drop the genre selection — and gate the grid query on the language + // being "committed" (below) so we don't fire one throwaway fetch with the stale-language genres and + // then a second after the clear; the query waits for both to settle into a single fetch. + useEffect(() => { + if (committedLang !== lang) { + setCommittedLang(lang); + if (filters.genres.length) setFilters({ ...filters, genres: [] }); + } + }, [lang, committedLang, filters, setFilters]); + const items = browseQ.data?.pages.flatMap((p) => p.items) ?? []; const total = browseQ.data?.pages[0]?.total ?? 0; // Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the @@ -215,6 +279,14 @@ export default function PlexBrowse() { infoScrollRef.current = 0; // fresh info page starts at the top sub.open({ kind: "info", id: card.id }); } + // Open a cast/crew member's filmography page (from an info/show cast row, or an actor filter-chip). + // Only when leaving an INFO page do we stash its scroll (so browser Back to that cast row lands where + // it was, like applyFilter) — from a show/grid context infoScrollRef must stay untouched, else a + // later info page would wrongly restore this stale offset. + function onOpenPerson(name: string) { + if (sub.view.kind === "info") infoScrollRef.current = scroller()?.scrollTop ?? 0; + sub.open({ kind: "person", name }); + } // Optimistically flip a card's status in every cached library page — both the title grid (`items`) // and the search "Episodes" section (`episodes`) — so the quick toggle reacts instantly and reliably // BOTH ways (mark and un-mark). An id matches in only one array, so touching both is safe. @@ -232,18 +304,11 @@ export default function PlexBrowse() { : old, ); } - async function toggleWatched(card: PlexCard) { - const next = card.status === "watched" ? "new" : "watched"; - optimisticStatus(card.id, next); - // On failure surface the server's reason (403/404/429) — the toggle used to fail silently, so the - // click looked dead. The invalidate below runs either way, resyncing the optimistic flip to truth. - try { - await api.plexSetState(card.id, next); - } catch (e) { - notifyActionError(e, t("plex.watchedFailed")); - } - qc.invalidateQueries({ queryKey: qk.plexLibrary() }); - } + const toggleWatched = (card: PlexCard) => + toggleWatchState(card, t, { + optimistic: (next) => optimisticStatus(card.id, next), + resync: () => qc.invalidateQueries({ queryKey: qk.plexLibrary() }), + }); // Apply a metadata filter (clicked on an info page / show hero / cast) then show the unified grid. // Array filters (genres/people/studios) UNION with what's set; scalars replace. Clicking a person // (cast/crew) widens to the 'both' scope so their movies AND shows show up together (mixed feed). @@ -295,6 +360,19 @@ export default function PlexBrowse() { onPlay={() => sub.open({ kind: "player", id: infoId })} onPlayItem={(id) => sub.open({ kind: "player", id })} onFilter={applyFilter} + onOpenPerson={onOpenPerson} + /> + ); + } + if (sub.view.kind === "person") { + const personName = sub.view.name; + return ( + ); } @@ -308,6 +386,7 @@ export default function PlexBrowse() { onOpenSeason={(seasonId) => sub.open({ kind: "season", showId, seasonId })} onOpenShow={(id) => sub.open({ kind: "show", id })} onFilter={applyFilter} + onOpenPerson={onOpenPerson} /> ); } @@ -328,7 +407,7 @@ export default function PlexBrowse() { {/* The title count is fixed chrome — it portals into the shell's fixed band so it stays put while only the poster grid scrolls. */} -
+

{browseQ.isLoading ? " " @@ -336,6 +415,18 @@ export default function PlexBrowse() { ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}

+ {/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */} +
+ {t("plex.filter.sort")} + setSort(next ?? PLEX_SORT_FALLBACK)} + label={t("plex.filter.sort")} + ascLabel={t("plex.filter.dir.asc")} + descLabel={t("plex.filter.dir.desc")} + /> +
@@ -346,26 +437,35 @@ export default function PlexBrowse() { {personCards.map((p) => (
- {p.photo ? ( - - ) : ( -
- + {/* The photo + name open the actor's filmography page; the ✕ removes the filter. */} + +
+ {q.isError && !d ? ( + q.refetch()}> + {t("common.loadError")} + + ) : q.isLoading || !d ? ( + + ) : ( +
+ {/* Hero: headshot + name + a "filter the library by this person" action. */} +
+
+ {d.photo ? ( + + ) : ( +
+ +
+ )} +
+
+

{d.name}

+

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

+ +
+
+ {empty ? ( +

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

+ ) : ( + <> + {section("plex.person.movies", d.movies)} + {section("plex.person.shows", d.shows)} + + )} +
+ )} +
+ ); +} + // --- Series drill-down: show detail page → season subpage → player ------------------------------- // Both the show page and the season page read the SAME cached qk.plexShow(showId) payload (the // season page just picks its season out of it), so opening a season is instant and PlexPlaylistAdd's @@ -797,14 +1023,17 @@ function SeasonCard({ ); } -function CastStrip({ +export function CastStrip({ cast, onFilter, + onOpenPerson, }: { cast: PlexCastMember[]; onFilter?: (patch: Partial) => void; + onOpenPerson?: (name: string) => void; }) { const { t } = useTranslation(); + const clickable = !!(onOpenPerson || onFilter); return (
{c.thumb ? ( @@ -827,18 +1056,24 @@ function CastStrip({ )}
{c.name}
{c.role &&
{c.role}
} ); - return onFilter ? ( + return clickable ? ( - )} + {(() => { + // IMDb score when present, else the Plex audience score — mirrors PlexInfo so a show + // with no imdb:// entry (imdb_rating NULL after the S6 IMDb-only change) still shows a + // rating instead of dropping the badge entirely. + const badge = pickRatingBadge(show.imdb_rating, show.rating); + if (!badge) return null; + const { score } = badge; + return ( + + ); + })()}
{show.genres.length > 0 && (
@@ -1284,7 +1531,9 @@ function PlexShowView({
- {show.cast.length > 0 && showCast && } + {show.cast.length > 0 && showCast && ( + + )} {d.related.length > 0 && showRelated && ( )} @@ -1318,7 +1567,11 @@ function PlexSeasonView({ }) { const { t } = useTranslation(); const qc = useQueryClient(); - const q = useQuery({ queryKey: qk.plexShow(showId), queryFn: () => api.plexShow(showId) }); + const lang = plexLang(); + const q = useQuery({ + queryKey: qk.plexShow(showId, lang), + queryFn: () => api.plexShow(showId, lang), + }); const d = q.data; const se = d?.seasons.find((s) => s.id === seasonId); const [addTarget, setAddTarget] = useState(null); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index 9440fdf4..10de6c2a 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -5,6 +5,7 @@ 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, @@ -31,6 +32,8 @@ type Props = { // When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets // the corresponding Plex filter (then the opener navigates to the filtered grid). onFilter?: (patch: Partial) => void; + // When provided, a cast member's photo/name opens their filmography page instead of filtering. + onOpenPerson?: (name: string) => void; // Play a sibling title from a collection strip (opens its player). onPlayItem?: (id: string) => void; // The item's library key — enables the admin "Manage collections" editor (page variant, movies). @@ -44,6 +47,7 @@ export default function PlexInfo({ onClose, onStateChange, onFilter, + onOpenPerson, onPlayItem, library, }: Props) { @@ -192,37 +196,42 @@ export default function PlexInfo({ {detail.studio} )} - {detail.imdb_rating != null && ( - // decorative rating gold — intentionally NOT a status token - - - {detail.imdb_url && ( - { + // 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 + + + {isImdb && detail.imdb_url && ( + + + + )} + + ); + })()}
{detail.tagline && ( @@ -341,12 +350,13 @@ export default function PlexInfo({
{cast.map((c, i) => { + const clickable = !!(onOpenPerson || onFilter); const inner = ( <>
{c.thumb ? ( @@ -358,7 +368,7 @@ export default function PlexInfo({
{c.name} @@ -372,11 +382,17 @@ export default function PlexInfo({ )} ); - return onFilter ? ( + return clickable ? ( + ))} +
+ ) : null + } + /> + ); +} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 236865cf..a1a051b1 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,16 +1,15 @@ import { useState } from "react"; 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 { api, plexFilterCount, type PlexFilters } from "../lib/api"; -import { healPlexSort, plexSortOptions, PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { useDebounced } from "../lib/useDebounced"; import { useScrollFade } from "../lib/useScrollFade"; import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; -import SortControl from "./SortControl"; import SegmentedToggle from "./SegmentedToggle"; import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; import { usePlexClearFilters } from "./usePlexActiveFilters"; @@ -66,14 +65,8 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl // Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and // the header search box); this rail is the primary editor of them. Opening a playlist is a // hand-off to PlexBrowse via the shared `playlistOpen` slot. - const { scope, show, sort, filters } = usePlex(); - const { - setScope, - setShow, - setSort, - setFilters, - setPlaylistOpen: onOpenPlaylist, - } = usePlexActions(); + 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(""); @@ -88,9 +81,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl onOpenPlaylist(pl.id); } const { collectionTitle: _ct, ...facetKey } = filters; + const lang = plexLang(); // HU/EN genre vocabulary — refetch facets on a language switch const facetsQ = useQuery({ - queryKey: qk.plexFacets(scope, show, facetKey), - queryFn: () => api.plexFacets(scope, filters, show), + queryKey: qk.plexFacets(scope, show, facetKey, lang), + queryFn: () => api.plexFacets(scope, filters, show, lang), enabled: !!scope, placeholderData: (prev) => prev, }); @@ -106,10 +100,9 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl const collections = collectionsQ.data?.collections ?? []; const fCount = plexFilterCount(filters); - // Heal against the current scope so a stale out-of-scope key (e.g. movie "duration" after switching - // to shows) doesn't inflate the active-filter count while the control + grid show the default. - const healedSort = healPlexSort(sort, scope); - const activeCount = fCount + (show !== "all" ? 1 : 0) + (healedSort.key !== "title" ? 1 : 0); + // Scope and sort moved to the header / above-the-grid toolbar (S3); the panel badge now counts only + // the filters that still live in this panel (expanded filters + watch-state). + const activeCount = fCount + (show !== "all" ? 1 : 0); const anyActive = activeCount > 0; const patch = (p: Partial) => setFilters({ ...filters, ...p }); @@ -117,7 +110,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v]; const clearAll = usePlexClearFilters(); - const sortOptions = plexSortOptions(scope, (k) => t(`plex.sort.${k}`)); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; @@ -128,23 +120,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl // so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved // layout and lets the user reorder / hide / collapse them. const items: PanelItem[] = [ - { - id: "scope", - title: t("plex.filter.scope"), - body: ( - ({ - value: s, - label: t(`plex.filter.scopeOpt.${s}`), - }))} - value={scope} - onChange={setScope} - /> - ), - }, { id: "playlists", title: t("plex.playlist.section"), @@ -199,20 +174,6 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl /> ), }, - { - id: "sort", - title: t("plex.filter.sort"), - body: ( - setSort(next ?? PLEX_SORT_FALLBACK)} - label={t("plex.filter.sort")} - ascLabel={t("plex.filter.dir.asc")} - descLabel={t("plex.filter.dir.desc")} - /> - ), - }, ...(peopleActive ? [ { @@ -377,7 +338,7 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl // duration (e.g. stale state after a bucket-boundary change) selects nothing, not "Any". value={ durBucketKey ?? - (filters.durationMin == null && filters.durationMax == null ? "" : "") + (filters.durationMin == null && filters.durationMax == null ? "" : " ") } options={[ { value: "", label: t("plex.filter.any") }, diff --git a/frontend/src/components/SearchBar.dom.test.tsx b/frontend/src/components/SearchBar.dom.test.tsx new file mode 100644 index 00000000..8c53178a --- /dev/null +++ b/frontend/src/components/SearchBar.dom.test.tsx @@ -0,0 +1,26 @@ +// @vitest-environment jsdom +// +// E5 S7: locks the container-query classes on the shared search box that fix the narrow-header +// collapse — the box must WRAP to its own line (not shrink the input to ~0) when the content column +// (@container/appmain) is narrow. jsdom does no layout, but the class presence is the contract these +// rules depend on, so a refactor that drops them fails here instead of only on a hand-resized window. +import { describe, expect, it, vi } from "vitest"; +import { render } from "@testing-library/react"; +import SearchBar from "./SearchBar"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +describe("SearchBar responsive wrapper (E5 S7)", () => { + it("carries the container-query classes that make it wrap instead of collapsing", () => { + const { container } = render( {}} placeholder="Search…" />); + const cls = (container.firstElementChild as HTMLElement).className; + // A usable min-width floor once the column has room → the box wraps via flex-wrap, never ~0: + expect(cls).toContain("@min-[420px]/appmain:min-w-[15rem]"); + // On a narrow column it takes a full line of its own and sorts last (small pills share the row above): + expect(cls).toContain("@max-[640px]/appmain:basis-full"); + expect(cls).toContain("@max-[640px]/appmain:order-last"); + }); +}); diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index aa4f1af7..dd8b7a0d 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -1,4 +1,9 @@ -import { type ReactNode } from "react"; +import { + type FocusEventHandler, + type InputHTMLAttributes, + type KeyboardEventHandler, + type ReactNode, +} from "react"; import { useTranslation } from "react-i18next"; import { Search, X } from "lucide-react"; @@ -6,6 +11,12 @@ import { Search, X } from "lucide-react"; // (feed, Plex, channels, playlists): a glass rounded box with a search icon, the input, a // clear button, and a "Go" trigger (Enter also fires it). The feed passes its live-YouTube // escalation as the Go action; the other modules filter as you type and Go just commits/blurs. +// +// Optional slots keep the box reusable while letting one module enrich it: `leading` replaces the +// search icon with a control (Plex's search scope-pill), and `below` renders an absolutely +// positioned panel attached under the box (Plex's instant typeahead results). `onFocus`/`onBlur`/ +// `onKeyDown` pass through to the input so a caller can drive that panel (open on focus, arrow-key +// navigation); the built-in Enter→Go fires only if the caller didn't `preventDefault()` first. export default function SearchBar({ value, onChange, @@ -15,6 +26,12 @@ export default function SearchBar({ goIcon, goDisabled, goTitle, + leading, + below, + onFocus, + onBlur, + onKeyDown, + inputProps, }: { value: string; onChange: (q: string) => void; @@ -25,46 +42,67 @@ export default function SearchBar({ goIcon?: ReactNode; goDisabled?: boolean; goTitle?: string; + leading?: ReactNode; + below?: ReactNode; + onFocus?: FocusEventHandler; + onBlur?: FocusEventHandler; + onKeyDown?: KeyboardEventHandler; + // Extra attributes spread onto the (e.g. Plex's combobox ARIA). Spread FIRST so the + // component's own value/handlers always win. + inputProps?: InputHTMLAttributes; }) { const { t } = useTranslation(); const fire = () => { if (onGo && !goDisabled) onGo(); }; + // Responsive rules on the SHARED search box (every module's header, not just Plex), keyed on the + // content column's width via `@container/appmain` (E5 S7): `min-w-0` by default so it never forces + // overflow on a truly tiny column; once the column has room (≥420px) a `min-w-[15rem]` floor makes + // the box WRAP to its own line via the header's flex-wrap instead of shrinking the input to ~0; on + // a narrow column (≤640px) it also takes a full line of its own (basis-full) and sorts LAST, so the + // small pills (module name + Plex scope toggle / feed sync chip) share the line above it. return ( -
- - onChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") fire(); - }} - placeholder={placeholder} - className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted" - /> - {value && ( - - )} - {onGo && ( - - )} +
+
+ {leading ?? } + onChange(e.target.value)} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={(e) => { + onKeyDown?.(e); + if (e.key === "Enter" && !e.defaultPrevented) fire(); + }} + placeholder={placeholder} + className="flex-1 min-w-0 bg-transparent outline-hidden text-sm placeholder:text-muted" + /> + {value && ( + + )} + {onGo && ( + + )} +
+ {below}
); } diff --git a/frontend/src/components/usePlexActiveFilters.dom.test.tsx b/frontend/src/components/usePlexActiveFilters.dom.test.tsx new file mode 100644 index 00000000..d141f4bd --- /dev/null +++ b/frontend/src/components/usePlexActiveFilters.dom.test.tsx @@ -0,0 +1,43 @@ +// @vitest-environment jsdom +// +// Guards the S3 behavior change: the active-filter chip row (usePlexActiveFilters) still emits chips +// for the in-panel narrowings (expanded filters + watch-state) but NO LONGER emits a sort chip — +// sort is an always-visible control above the grid now, like the Feed. i18n is key-passthrough and +// the provider is mocked so the hook can be driven with a non-default sort present. +import { describe, it, expect, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { EMPTY_PLEX_FILTERS } from "../lib/api"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); + +const plexState = { + scope: "both", + show: "watched", + // A non-default sort is present in the shared state — the hook must IGNORE it (no sort chip). + sort: { key: "year", dir: "asc" }, + filters: { ...EMPTY_PLEX_FILTERS, genres: ["Action"] }, +}; +const setShow = vi.fn(); +const setFilters = vi.fn(); +vi.mock("./PlexProvider", () => ({ + usePlex: () => plexState, + usePlexActions: () => ({ setShow, setFilters }), +})); + +import { usePlexActiveFilters } from "./usePlexActiveFilters"; + +describe("usePlexActiveFilters", () => { + it("emits filter + watch-state chips but never a sort chip", () => { + const { result } = renderHook(() => usePlexActiveFilters()); + const keys = result.current.map((c) => c.key); + // The in-panel narrowings still surface... + expect(keys).toContain("genre:Action"); + expect(keys).toContain("show"); + // ...but sort does not, even though a non-default sort is active in the shared state. + expect(keys).not.toContain("sort"); + expect(result.current.every((c) => !c.key.startsWith("sort"))).toBe(true); + }); +}); diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts index 4b12e019..5283cd44 100644 --- a/frontend/src/components/usePlexActiveFilters.ts +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -1,19 +1,20 @@ import { useTranslation } from "react-i18next"; import { type ActiveFilter } from "../lib/useActiveFilters"; import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; -import { healPlexSort, PLEX_SORT_FALLBACK } from "../lib/plexSort"; +import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { usePlex, usePlexActions } from "./PlexProvider"; -// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state + -// sort) into the source-agnostic ActiveFilter[] the shared ActiveFilterChips renders above the grid. +// Plex's analogue of useActiveFilters: turns the shared Plex state (expanded filters + watch-state) +// into the source-agnostic ActiveFilter[] the shared ActiveFilterChips renders above the grid. // Plex thereby gets the same at-a-glance "what's narrowing this" row — with per-chip removal — that // the feed has, instead of the narrowing being visible only inside the collapsed sidebar. Labels reuse // the existing plex.* i18n keys; multi-value dimensions (genres, people, ratings) emit one chip each so -// removing one keeps the rest (same shape as the feed's tag/channel chips). Lives here, not in lib, -// because it reads the Plex provider (lib stays provider-free, like useActiveFilters). +// removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here — +// it's an always-visible control above the grid (S3), like the feed. Lives here, not in lib, because +// it reads the Plex provider (lib stays provider-free, like useActiveFilters). export function usePlexActiveFilters(): ActiveFilter[] { - const { scope, show, sort, filters } = usePlex(); - const { setShow, setSort, setFilters } = usePlexActions(); + const { show, filters } = usePlex(); + const { setShow, setFilters } = usePlexActions(); const { t } = useTranslation(); const patch = (p: Partial) => setFilters({ ...filters, ...p }); const out: ActiveFilter[] = []; @@ -108,14 +109,6 @@ export function usePlexActiveFilters(): ActiveFilter[] { remove: () => setShow("all"), }); } - const healed = healPlexSort(sort, scope); - if (healed.key !== "title") { - out.push({ - key: "sort", - label: t("plex.sort." + healed.key), - remove: () => setSort(PLEX_SORT_FALLBACK), - }); - } return out; } diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts index ee0cd262..fca55657 100644 --- a/frontend/src/i18n/index.ts +++ b/frontend/src/i18n/index.ts @@ -56,4 +56,11 @@ export function setLanguage(code: LangCode): void { document.documentElement.lang = code; } +// The Plex overlay serves only EN or HU metadata (E5 S6). Collapse the active UI language to one of +// those for the `?lang=` param / query keys. Components read `i18n.language` off useTranslation (which +// re-renders on switch); this singleton reader is for non-component code (e.g. plexPlayerHooks). +export function plexLang(): "en" | "hu" { + return i18n.language?.startsWith("hu") ? "hu" : "en"; +} + export default i18n; diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 8f941a83..f4cd4060 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -3,6 +3,17 @@ "backToFeed": "YouTube feed", "backToLibrary": "Back to library", "searchPlaceholder": "Search Plex…", + "searchField": { + "aria": "Search scope", + "all": "Everywhere", + "title": "Titles", + "people": "Cast & crew", + "summary": "Descriptions", + "episode": "Episodes" + }, + "suggest": { + "aria": "Search suggestions" + }, "sort": { "added": "Recently added", "title": "Title", @@ -192,6 +203,7 @@ "filterYear": "Show titles from {{year}}", "filterRating": "Show titles rated {{n}}+", "filterActor": "Show titles with {{name}}", + "viewPerson": "View {{name}}'s filmography", "browseCollection": "Browse collection →" }, "playable": { @@ -234,5 +246,12 @@ "count_one": "{{count}} title", "count_other": "{{count}} titles", "remove": "Remove from filter" + }, + "person": { + "credits": "{{movies}} movies · {{shows}} shows", + "filterBy": "Filter library by {{name}}", + "movies": "Movies ({{count}})", + "shows": "Shows ({{count}})", + "empty": "No titles found for this person." } } diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 04d50378..eeb7dd58 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -3,6 +3,17 @@ "backToFeed": "YouTube feed", "backToLibrary": "Vissza a könyvtárhoz", "searchPlaceholder": "Keresés a Plexben…", + "searchField": { + "aria": "Keresési hatókör", + "all": "Mindenhol", + "title": "Címek", + "people": "Szereplők", + "summary": "Leírások", + "episode": "Epizódok" + }, + "suggest": { + "aria": "Keresési javaslatok" + }, "sort": { "added": "Nemrég hozzáadott", "title": "Cím", @@ -192,6 +203,7 @@ "filterYear": "{{year}} évi címek", "filterRating": "{{n}}+ pontos címek", "filterActor": "Címek {{name}} szereplésével", + "viewPerson": "{{name}} filmográfiája", "browseCollection": "Kollekció böngészése →" }, "playable": { @@ -234,5 +246,12 @@ "count_one": "{{count}} cím", "count_other": "{{count}} cím", "remove": "Eltávolítás a szűrőből" + }, + "person": { + "credits": "{{movies}} film · {{shows}} sorozat", + "filterBy": "Szűrés erre: {{name}}", + "movies": "Filmek ({{count}})", + "shows": "Sorozatok ({{count}})", + "empty": "Nincs találat ehhez a személyhez." } } diff --git a/frontend/src/i18n/parity.test.ts b/frontend/src/i18n/parity.test.ts new file mode 100644 index 00000000..72369f7c --- /dev/null +++ b/frontend/src/i18n/parity.test.ts @@ -0,0 +1,52 @@ +// E5 S6: every locale namespace must carry the SAME key set in English and Hungarian. A key present +// in one language but not the other renders the raw key string (missing translation) or is dead +// weight (orphaned translation); this test fails loudly on either, per namespace. +import { describe, expect, it } from "vitest"; + +const mods = import.meta.glob("./locales/*/*.json", { eager: true }) as Record< + string, + { default: Record } +>; + +function flatten(obj: Record, prefix = ""): string[] { + const out: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const key = prefix ? `${prefix}.${k}` : k; + if (v && typeof v === "object" && !Array.isArray(v)) { + out.push(...flatten(v as Record, key)); + } else { + out.push(key); + } + } + return out.sort(); +} + +// { namespace: { en: {...}, hu: {...} } } +const byNs: Record>> = {}; +for (const [path, mod] of Object.entries(mods)) { + const m = path.match(/\/locales\/([^/]+)\/([^/]+)\.json$/); + const lang = m?.[1]; + const ns = m?.[2]; + if (!lang || !ns) continue; + (byNs[ns] ??= {})[lang] = mod.default; +} + +describe("i18n en/hu key parity", () => { + it("loaded both languages for every namespace", () => { + expect(Object.keys(byNs).length).toBeGreaterThan(0); + for (const [ns, langs] of Object.entries(byNs)) { + expect({ ns, langs: Object.keys(langs).sort() }).toEqual({ ns, langs: ["en", "hu"] }); + } + }); + + for (const [ns, langs] of Object.entries(byNs)) { + it(`${ns}: en and hu declare identical keys`, () => { + const en = flatten(langs.en ?? {}); + const hu = flatten(langs.hu ?? {}); + expect({ + missingInHu: en.filter((k) => !hu.includes(k)), + missingInEn: hu.filter((k) => !en.includes(k)), + }).toEqual({ missingInHu: [], missingInEn: [] }); + }); + } +}); diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 2fea4492..9257b02f 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -4,6 +4,7 @@ // touch only the shared `req`/`beacon` from core plus the local `appendPlexFilters` helper. import { req, beacon } from "./core"; import { type SortState } from "../columnSort"; +import { type PlexSearchField } from "../plexSearch"; // Admin: result of testing the Plex server connection (also drives the library-picker). interface PlexSection { @@ -120,6 +121,7 @@ export interface PlexItemDetail { imdb_rating?: number | null; imdb_id?: string | null; imdb_url?: string | null; + rating?: number | null; // Plex audience score — shown when there's no IMDb score content_rating?: string | null; genres: string[]; directors: string[]; @@ -180,6 +182,12 @@ export interface PlexPersonCard { photo?: string | null; // proxied person-image url, or null (placeholder) count: number; // in-scope movies/shows featuring them } +export interface PlexPersonDetail { + name: string; + photo?: string | null; // cached headshot, or null (placeholder) + movies: PlexCard[]; // enabled-library movies whose cast OR directors include this person + shows: PlexCard[]; // enabled-library shows, same +} // The expanded Plex browse filters (movie libraries). Persisted per-account as JSON. export interface PlexFilters { genres: string[]; @@ -275,29 +283,39 @@ export const plexApi = { plexLibrary: (p: { scope: string; // movie | show | both q?: string; + field?: PlexSearchField; // search scope-pill (all | title | people | summary | episode) sort?: SortState; show?: string; + lang?: string; // active UI language: "hu" serves the HU overlay twin, else EN (default) offset?: number; limit?: number; filters?: PlexFilters; }): Promise => { const u = new URLSearchParams({ scope: p.scope }); if (p.q) u.set("q", p.q); + if (p.field && p.field !== "all") u.set("field", p.field); if (p.sort) { u.set("sort", p.sort.key); u.set("sort_dir", p.sort.dir); } if (p.show && p.show !== "all") u.set("show", p.show); + if (p.lang === "hu") u.set("lang", "hu"); // en is the server default; omit to keep URLs stable if (p.offset) u.set("offset", String(p.offset)); if (p.limit) u.set("limit", String(p.limit)); if (p.filters) appendPlexFilters(u, p.filters); return req(`/api/plex/library?${u.toString()}`); }, // Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group - // narrows the others and the bounds match the visible result set. - plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise => { + // narrows the others and the bounds match the visible result set. `lang` picks the genre vocabulary. + plexFacets: ( + scope: string, + f?: PlexFilters, + show?: string, + lang?: string, + ): Promise => { const u = new URLSearchParams({ scope }); if (show && show !== "all") u.set("show", show); + if (lang === "hu") u.set("lang", "hu"); if (f) appendPlexFilters(u, f); return req(`/api/plex/facets?${u.toString()}`); }, @@ -391,8 +409,8 @@ export const plexApi = { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }), }), - plexShow: (id: string): Promise => - req(`/api/plex/show/${encodeURIComponent(id)}`), + plexShow: (id: string, lang?: string): Promise => + req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`), // Mark a whole show / season watched or unwatched (all its episodes) for the current user. plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> => req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { @@ -404,13 +422,16 @@ export const plexApi = { method: "POST", body: JSON.stringify({ watched }), }), - plexItem: (id: string): Promise => - req(`/api/plex/item/${encodeURIComponent(id)}`), + plexItem: (id: string, lang?: string): Promise => + req(`/api/plex/item/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`), // Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count. plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> => req( `/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`, ), + // One cast/crew member's filmography: cached headshot + movies and shows (cast OR directors). + plexPerson: (name: string): Promise => + req(`/api/plex/person/${encodeURIComponent(name)}`), plexSession: ( id: string, start = 0, diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts index a8aad5c9..ed443e84 100644 --- a/frontend/src/lib/panelLayout.ts +++ b/frontend/src/lib/panelLayout.ts @@ -18,10 +18,8 @@ export interface PanelLayout { const PANEL_ORDER: Record = { feed: ["savedviews", "date", "channel", "tags", "language", "topic"], plex: [ - "scope", "playlists", "watch", - "sort", "people", "collection", "rating", diff --git a/frontend/src/lib/plexPlayerHooks.ts b/frontend/src/lib/plexPlayerHooks.ts index 8c684a55..91baf42a 100644 --- a/frontend/src/lib/plexPlayerHooks.ts +++ b/frontend/src/lib/plexPlayerHooks.ts @@ -17,6 +17,7 @@ import { } from "./fullscreen"; import { api, type PlexItemDetail, type PlexMarker } from "./api"; import { qk } from "./queryKeys"; +import { plexLang } from "../i18n"; import { type PlexPlayerPrefs } from "./plexPlayerPrefs"; import { PLEX_SCALE, normalizeVolumeIn, stepVolumeIn } from "./playerVolume"; @@ -126,7 +127,10 @@ export function usePlexProgress(opts: { /* eslint-disable react-hooks/exhaustive-deps */ if (absRef.current > 0 && durationRef.current > 0) { const pos = Math.floor(absRef.current); - qc.setQueryData(qk.plexItem(id), (old) => (old ? { ...old, position_seconds: pos } : old)); + // Match the language-keyed detail query (E5 S6) so the optimistic resume patch lands. + qc.setQueryData(qk.plexItem(id, plexLang()), (old) => + old ? { ...old, position_seconds: pos } : old, + ); } /* eslint-enable react-hooks/exhaustive-deps */ qc.invalidateQueries({ queryKey: qk.plexLibrary() }); diff --git a/frontend/src/lib/plexRating.test.ts b/frontend/src/lib/plexRating.test.ts new file mode 100644 index 00000000..dab7801a --- /dev/null +++ b/frontend/src/lib/plexRating.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { pickRatingBadge } from "./plexRating"; + +describe("pickRatingBadge", () => { + it("prefers the IMDb score and marks it as IMDb (gets the imdb.com link)", () => { + expect(pickRatingBadge(8.6, 6.0)).toEqual({ score: 8.6, isImdb: true }); + }); + + it("falls back to the audience score without IMDb branding when there's no IMDb score", () => { + expect(pickRatingBadge(null, 7.2)).toEqual({ score: 7.2, isImdb: false }); + expect(pickRatingBadge(undefined, 7.2)).toEqual({ score: 7.2, isImdb: false }); + }); + + it("treats a 0 IMDb score as present (not falsy-skipped)", () => { + expect(pickRatingBadge(0, 5)).toEqual({ score: 0, isImdb: true }); + }); + + it("returns null when neither score exists (no badge rendered)", () => { + expect(pickRatingBadge(null, null)).toBeNull(); + expect(pickRatingBadge(undefined, undefined)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/plexRating.ts b/frontend/src/lib/plexRating.ts new file mode 100644 index 00000000..40cf6af2 --- /dev/null +++ b/frontend/src/lib/plexRating.ts @@ -0,0 +1,12 @@ +// The rating badge shows the IMDb score when we have one (rendered with the imdb.com link), +// otherwise the Plex audience score (no IMDb link/branding) — so a non-IMDb score is never +// mislabelled as IMDb (E5 S6). Shared by the media-info panel (PlexInfo) and the show hero +// (PlexBrowse) so both surfaces agree. Returns null when neither score exists (no badge). +export function pickRatingBadge( + imdbRating?: number | null, + audienceRating?: number | null, +): { score: number; isImdb: boolean } | null { + const score = imdbRating ?? audienceRating; + if (score == null) return null; + return { score, isImdb: imdbRating != null }; +} diff --git a/frontend/src/lib/plexSearch.ts b/frontend/src/lib/plexSearch.ts new file mode 100644 index 00000000..7c027124 --- /dev/null +++ b/frontend/src/lib/plexSearch.ts @@ -0,0 +1,26 @@ +// The search-box scope-pill (S2): one field-scope active at a time, narrowing the Plex full-text +// search to a single facet of the row. Kept in one module so the provider (state), the header +// SearchBar (the pill UI), and the api layer (the `field` query param) share one source of truth. + +export type PlexSearchField = "all" | "title" | "people" | "summary" | "episode"; + +const PLEX_SEARCH_FIELDS: PlexSearchField[] = ["all", "title", "people", "summary", "episode"]; + +// i18n label key per scope (see plex.searchField.* in the locale files). +export const plexFieldLabelKey = (f: PlexSearchField): string => `plex.searchField.${f}`; + +// Episodes only exist under show libraries, so the Episode scope is offered only when shows are in +// scope (show | both) — mirroring the backend, which returns nothing for episode+movie-only. +export function availablePlexFields(scope: string): PlexSearchField[] { + return scope === "movie" ? PLEX_SEARCH_FIELDS.filter((f) => f !== "episode") : PLEX_SEARCH_FIELDS; +} + +// Heal a stored/selected field against the current scope: a movie-only scope can't hold Episode, so +// it falls back to Everywhere (analogous to healPlexSort). Applied at the point of use. +export function healPlexField(field: PlexSearchField, scope: string): PlexSearchField { + return availablePlexFields(scope).includes(field) ? field : "all"; +} + +export function parsePlexField(v: string | null | undefined): PlexSearchField { + return v && (PLEX_SEARCH_FIELDS as string[]).includes(v) ? (v as PlexSearchField) : "all"; +} diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 611b5cd2..145569c3 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -91,9 +91,14 @@ export const qk = { plex: () => ["plex"] as const, plexWatchLink: () => ["plex-watch-link"] as const, plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], - plexShow: (showId?: string) => - showId !== undefined ? (["plex-show", showId] as const) : (["plex-show"] as const), - plexItem: (id: string) => ["plex-item", id] 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. + plexShow: (showId?: string, lang?: string) => + showId !== undefined + ? (["plex-show", showId, ...(lang ? [lang] : [])] as const) + : (["plex-show"] as const), + plexItem: (id: string, lang?: string) => ["plex-item", id, ...(lang ? [lang] : [])] as const, plexPlaylists: (...rest: unknown[]) => ["plex-playlists", ...rest], plexPlaylist: (playlistId: number) => ["plex-playlist", playlistId] as const, plexCollections: (...rest: unknown[]) => ["plex-collections", ...rest], @@ -101,4 +106,5 @@ export const qk = { plexFacets: (...rest: unknown[]) => ["plex-facets", ...rest], plexPeopleCards: (scope: string, actors: string[]) => ["plex-people-cards", scope, actors] as const, + plexPerson: (name: string) => ["plex-person", name] as const, }; diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index c94eab55..2ce2cdb4 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,22 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.66.0", + date: "2026-08-17", + summary: + "A big Plex update: Hungarian metadata, scoped search with instant suggestions, cast & crew pages, and a tidier toolbar that finally works on a phone.", + features: [ + "Hungarian Plex library: with the app in Hungarian, film and show titles, summaries and genres appear in Hungarian (falling back to English where a title has no Hungarian version). Search and the genre filter work in the Hungarian vocabulary too, and switching language clears a genre selection so it can't silently hide everything.", + "Scoped Plex search: a pill in the search box narrows a search to Titles, Cast, Descriptions or Episodes, and a live list of matching titles drops down as you type — jumping straight to a film's page or a show's episodes.", + "Cast & crew pages: click an actor or director anywhere in Plex to see their filmography across your libraries, or filter the library by that person.", + "Ratings now separate an IMDb score (shown with a link to IMDb) from Plex's own audience score, so a title with no IMDb rating still shows its audience score instead of nothing.", + "Plex toolbar tidy-up: the movie/show scope sits next to the search box and the sort control sits above the cards (like the feed), and the header no longer collapses or overlaps its own controls on a narrow window or a phone.", + ], + chores: [ + "Large internal refactor of the Plex module (search/card/query builders split out, the feed read-model extracted, the database queries modernized) backed by characterization tests, plus a full-codebase review. No behaviour change intended.", + ], + }, { version: "0.65.2", date: "2026-08-16", diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 5fdb2cac..65638887 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -55,6 +55,7 @@ export const LS = { plexSort: "siftlode.plexSort", plexFilters: "siftlode.plexFilters", plexQ: "siftlode.plexQ", + plexField: "siftlode.plexField", plexPlaylistLayout: "siftlode.plexPlaylistLayout", plexPlayerPrefs: "siftlode.plexPlayerPrefs", // The YouTube modal player's volume (`{ volume: 0–100 }`; 0 IS muted — there is no separate