release: 0.66.0 (e5-s3-toolbar-relocation)

This commit is contained in:
2026-08-17 22:19:23 +02:00
69 changed files with 4632 additions and 1277 deletions
+1 -1
View File
@@ -1 +1 @@
0.65.2
0.66.0
@@ -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")
@@ -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")
@@ -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")
+4
View File
@@ -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.
+426
View File
@@ -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
+55
View File
@@ -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):
+65
View File
@@ -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)
+4
View File
@@ -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,
},
+59
View File
@@ -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
+5 -4
View File
@@ -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
+16
View File
@@ -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()
+5 -2
View File
@@ -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")
+191
View File
@@ -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("_", "\\_")
+6 -4
View File
@@ -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}
+192 -29
View File
@@ -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 '<provider>:<id>' (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()
+40 -20
View File
@@ -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:
+1 -1
View File
@@ -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"])
+1 -2
View File
@@ -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
+1 -1
View File
@@ -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"])
+26 -426
View File
@@ -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},
+1 -1
View File
@@ -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,
+2 -1
View File
@@ -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
+14 -8
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+31 -11
View File
@@ -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}
+1 -2
View File
@@ -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"])
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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
+1
View File
@@ -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
+13
View File
@@ -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)
+45
View File
@@ -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"] == []
+303
View File
@@ -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"]
@@ -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"]
+125
View File
@@ -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)
+223
View File
@@ -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]
+114
View File
@@ -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
+357
View File
@@ -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
+7 -1
View File
@@ -351,7 +351,13 @@ export default function App() {
/>
</Suspense>
)}
<div className="relative flex-1 min-w-0 flex flex-col">
{/* `@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. */}
<div className="@container/appmain relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<PageShell scrollKey={`channel:${channelView.id}`}>
<Suspense fallback={pageFallback}>
+26 -23
View File
@@ -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 = (
<SearchBar
value={plexQ}
placeholder={t("plex.searchPlaceholder")}
onChange={setPlexQ}
onGo={blur}
/>
);
search = <PlexSearchBox />;
} else if (page === "channels") {
search = (
<SearchBar
@@ -102,7 +97,10 @@ export default function Header({
// Absolutely positioned within the (relative) content column, so its left edge tracks the
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
// slim and/or no filter sidebar is present — no hard-coded offset.
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex items-center gap-3">
<div
ref={ref}
className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex flex-wrap items-center gap-x-3 gap-y-2"
>
<ModuleName
label={t(moduleLabelKey[page])}
labels={moduleLabels}
@@ -112,6 +110,9 @@ export default function Header({
canNext={multiModule}
/>
{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 && <PlexScopeToggle />}
{/* 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({
)}
</div>
);
}
});
export default Header;
@@ -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 }) => <div>{children}</div>,
}));
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(
<PageShell header={<div data-testid="hdr">H</div>}>content</PageShell>,
);
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(<PageShell>content</PageShell>);
expect(container.querySelector('[class*="pt-(--hdr-h)"]')).toBeNull();
});
});
+43 -3
View File
@@ -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<HTMLDivElement | null>(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<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(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<HTMLDivElement> }>, {
ref: headerRef,
})
: header;
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
{header}
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}>
{measuredHeader}
<div
ref={contentRef}
className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-(--hdr-h)" : ""}`}
>
{banners}
<div ref={setToolbarSlot} className="shrink-0" />
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
+314 -61
View File
@@ -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<Sub>({ 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 (
<PlexPersonView
name={personName}
onBack={sub.back}
onOpenCard={onCard}
onInfoCard={onInfo}
onFilter={applyFilter}
/>
);
}
@@ -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. */}
<PageToolbar>
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto">
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto flex items-center gap-2">
<p className="text-xs text-muted">
{browseQ.isLoading
? " "
@@ -336,6 +415,18 @@ export default function PlexBrowse() {
? t("plex.searchCount", { count: total })
: t("plex.count", { count: total })}
</p>
{/* Sort moved out of the filter sidebar to the row above the cards (S3), like the Feed. */}
<div className="ml-auto flex items-center gap-2">
<span className="hidden sm:inline text-xs text-muted">{t("plex.filter.sort")}</span>
<SortControl
options={sortOptions}
sort={sortState}
onChange={(next) => setSort(next ?? PLEX_SORT_FALLBACK)}
label={t("plex.filter.sort")}
ascLabel={t("plex.filter.dir.asc")}
descLabel={t("plex.filter.dir.desc")}
/>
</div>
</div>
</PageToolbar>
@@ -346,26 +437,35 @@ export default function PlexBrowse() {
{personCards.map((p) => (
<div
key={p.name}
className="glass-card flex items-center gap-2.5 rounded-xl py-1.5 pl-1.5 pr-2.5"
className="glass-card flex items-center gap-1 rounded-xl py-1.5 pl-1.5 pr-2.5"
>
{p.photo ? (
<img
src={p.photo}
alt=""
loading="lazy"
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
<User className="w-5 h-5" />
{/* The photo + name open the actor's filmography page; the ✕ removes the filter. */}
<button
onClick={() => onOpenPerson(p.name)}
title={t("plex.info.viewPerson", { name: p.name })}
className="group/person flex min-w-0 items-center gap-2.5 rounded-lg text-left"
>
{p.photo ? (
<img
src={p.photo}
alt=""
loading="lazy"
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
<User className="w-5 h-5" />
</div>
)}
<div className="min-w-0">
<div className="text-sm font-medium truncate max-w-[150px] group-hover/person:text-accent">
{p.name}
</div>
<div className="text-2xs text-muted">
{t("plex.people.count", { count: p.count })}
</div>
</div>
)}
<div className="min-w-0">
<div className="text-sm font-medium truncate max-w-[150px]">{p.name}</div>
<div className="text-2xs text-muted">
{t("plex.people.count", { count: p.count })}
</div>
</div>
</button>
<button
onClick={() => removeActor(p.name)}
title={t("plex.people.remove")}
@@ -603,15 +703,18 @@ function PlexInfoView({
onPlay,
onPlayItem,
onFilter,
onOpenPerson,
}: {
id: string;
onBack: () => void;
onPlay: () => void;
onPlayItem: (id: string) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
onOpenPerson: (name: string) => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
const lang = plexLang();
const q = useQuery({ queryKey: qk.plexItem(id, lang), queryFn: () => api.plexItem(id, lang) });
return (
<div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0">
@@ -638,6 +741,7 @@ function PlexInfoView({
onPlay={onPlay}
onPlayItem={onPlayItem}
onFilter={onFilter}
onOpenPerson={onOpenPerson}
// No onStateChange: PlexInfo.setState already invalidates qk.plexItem(id) (this very
// query), so q refetches automatically — a manual refetch here just double-fetched.
/>
@@ -647,6 +751,128 @@ function PlexInfoView({
);
}
// --- Actor detail: a cast/crew member's filmography (movies + shows in one place) ---------------
// The inverse of the cast-name → library-filter link: opened from a cast row (info/show page), it
// shows the person's headshot + name, a "filter the library by them" action, and their titles as two
// poster-card sections (Movies / Shows). Mirrors PlexInfoView's fetch-wrapper + glass-hero shape.
export function PlexPersonView({
name,
onBack,
onOpenCard,
onInfoCard,
onFilter,
}: {
name: string;
onBack: () => void;
onOpenCard: (card: PlexCard) => void;
onInfoCard: (card: PlexCard) => void;
onFilter: (patch: Partial<PlexFilters>) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const q = useQuery({ queryKey: qk.plexPerson(name), queryFn: () => api.plexPerson(name) });
// Quick watch-toggle from a filmography card: optimistically flip in THIS person's cached payload
// (both the movies and shows arrays — an id matches only one), then persist + resync via the shared
// helper; also invalidate the library so its grid agrees.
const toggle = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) =>
qc.setQueryData<PlexPersonDetail>(qk.plexPerson(name), (old) =>
old
? {
...old,
movies: old.movies.map((m) => (m.id === card.id ? { ...m, status: next } : m)),
shows: old.shows.map((s) => (s.id === card.id ? { ...s, status: next } : s)),
}
: old,
),
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexPerson(name) });
qc.invalidateQueries({ queryKey: qk.plexLibrary() });
},
});
const section = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
<section className="mb-8">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => (
<PlexPosterCard
key={c.id}
card={c}
onOpen={() => onOpenCard(c)}
onInfo={() => onInfoCard(c)}
onToggleWatched={toggle}
/>
))}
</div>
</section>
) : null;
const d = q.data;
const empty = !!d && d.movies.length === 0 && d.shows.length === 0;
return (
<div className="w-[90%] max-w-[1600px] mx-auto">
<div className="p-4 pb-0">
<button
onClick={onBack}
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
>
<ArrowLeft className="w-4 h-4" />
{t("plex.backToLibrary")}
</button>
</div>
{q.isError && !d ? (
<StateMessage tone="error" onRetry={() => q.refetch()}>
{t("common.loadError")}
</StateMessage>
) : q.isLoading || !d ? (
<LoadingState label={t("plex.loading")} />
) : (
<div className="p-4">
{/* Hero: headshot + name + a "filter the library by this person" action. */}
<section className="glass-card rounded-2xl p-4 sm:p-5 mb-6 flex items-center gap-4">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-full border border-border bg-surface">
{d.photo ? (
<img src={d.photo} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center opacity-40">
<User className="h-8 w-8" />
</div>
)}
</div>
<div className="min-w-0">
<h1 className="truncate text-xl font-semibold">{d.name}</h1>
<p className="mb-2 text-xs text-muted">
{t("plex.person.credits", {
movies: d.movies.length,
shows: d.shows.length,
})}
</p>
<button
onClick={() => onFilter({ actors: [d.name] })}
className="rounded-full border border-border bg-card px-3 py-1 text-xs font-medium transition hover:border-accent hover:text-accent"
>
{t("plex.person.filterBy", { name: d.name })}
</button>
</div>
</section>
{empty ? (
<p className="text-sm text-muted">{t("plex.person.empty")}</p>
) : (
<>
{section("plex.person.movies", d.movies)}
{section("plex.person.shows", d.shows)}
</>
)}
</div>
)}
</div>
);
}
// --- 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<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const clickable = !!(onOpenPerson || onFilter);
return (
<section
className="glass-card rounded-2xl p-4 sm:p-5 mb-6"
@@ -816,7 +1045,7 @@ function CastStrip({
const inner = (
<>
<div
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${onFilter ? "border-border group-hover/cast:border-accent" : "border-border"}`}
className={`mx-auto h-20 w-20 overflow-hidden rounded-full border bg-surface ${clickable ? "border-border group-hover/cast:border-accent" : "border-border"}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
@@ -827,18 +1056,24 @@ function CastStrip({
)}
</div>
<div
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${onFilter ? "group-hover/cast:text-accent" : ""}`}
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
>
{c.name}
</div>
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
</>
);
return onFilter ? (
return clickable ? (
<button
key={i}
onClick={() => onFilter({ actors: [c.name] })}
title={t("plex.info.filterActor", { name: c.name })}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0 text-center"
>
{inner}
@@ -1078,6 +1313,7 @@ function PlexShowView({
onOpenSeason,
onOpenShow,
onFilter,
onOpenPerson,
}: {
showId: string;
onBack: () => void;
@@ -1085,11 +1321,16 @@ function PlexShowView({
onOpenSeason: (seasonId: string) => void;
onOpenShow: (id: string) => void;
onFilter?: (patch: Partial<PlexFilters>) => void;
onOpenPerson?: (name: string) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const isAdmin = qc.getQueryData<{ role?: string }>(qk.me())?.role === "admin";
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 [addTarget, setAddTarget] = useState<PlexAddTarget | null>(null);
const [collOpen, setCollOpen] = useState(false);
@@ -1182,20 +1423,26 @@ function PlexShowView({
{show.season_count != null && (
<span>· {t("plex.seasons", { count: show.season_count })}</span>
)}
{show.imdb_rating != null && (
<button
onClick={
onFilter
? () => onFilter({ ratingMin: Math.floor(show.imdb_rating!) })
: undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{show.imdb_rating}
</button>
)}
{(() => {
// 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 (
<button
onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:text-accent" : "cursor-default"}`}
>
{/* decorative rating gold — intentionally NOT a status token */}
· <Star className="w-3.5 h-3.5 text-amber-400" fill="currentColor" />
{score.toFixed(1)}
</button>
);
})()}
</div>
{show.genres.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5">
@@ -1284,7 +1531,9 @@ function PlexShowView({
</div>
</section>
{show.cast.length > 0 && showCast && <CastStrip cast={show.cast} onFilter={onFilter} />}
{show.cast.length > 0 && showCast && (
<CastStrip cast={show.cast} onFilter={onFilter} onOpenPerson={onOpenPerson} />
)}
{d.related.length > 0 && showRelated && (
<RelatedStrip related={d.related} onOpen={onOpenShow} />
)}
@@ -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<PlexAddTarget | null>(null);
+51 -35
View File
@@ -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<PlexFilters>) => 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}
</Filterable>
)}
{detail.imdb_rating != null && (
// decorative rating gold — intentionally NOT a status token
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={
onFilter
? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) })
: undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
title={
onFilter
? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) })
: undefined
}
>
<Star className="w-3.5 h-3.5 fill-current" />
{detail.imdb_rating.toFixed(1)}
</button>
{detail.imdb_url && (
<a
href={detail.imdb_url}
target="_blank"
rel="noopener noreferrer"
title={t("plex.info.openImdb")}
{(() => {
// IMDb score when we have one (with the IMDb link); otherwise the Plex audience
// score (no IMDb link/branding, so a non-IMDb score is never mislabelled as IMDb).
const badge = pickRatingBadge(detail.imdb_rating, detail.rating);
if (!badge) return null;
const { score, isImdb } = badge;
return (
// decorative rating gold — intentionally NOT a status token
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
<button
onClick={
onFilter ? () => onFilter({ ratingMin: Math.floor(score) }) : undefined
}
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
title={
onFilter
? t("plex.info.filterRating", { n: Math.floor(score) })
: undefined
}
>
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
)}
<Star className="w-3.5 h-3.5 fill-current" />
{score.toFixed(1)}
</button>
{isImdb && detail.imdb_url && (
<a
href={detail.imdb_url}
target="_blank"
rel="noopener noreferrer"
title={t("plex.info.openImdb")}
>
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
</a>
)}
</span>
);
})()}
</div>
{detail.tagline && (
@@ -341,12 +350,13 @@ export default function PlexInfo({
</h2>
<div className="flex gap-3 overflow-x-auto pb-2">
{cast.map((c, i) => {
const clickable = !!(onOpenPerson || onFilter);
const inner = (
<>
<div
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
} ${onFilter ? "group-hover/cast:border-accent" : ""}`}
} ${clickable ? "group-hover/cast:border-accent" : ""}`}
>
{c.thumb ? (
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
@@ -358,7 +368,7 @@ export default function PlexInfo({
</div>
<div
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
onFilter ? "group-hover/cast:text-accent" : ""
clickable ? "group-hover/cast:text-accent" : ""
}`}
>
{c.name}
@@ -372,11 +382,17 @@ export default function PlexInfo({
)}
</>
);
return onFilter ? (
return clickable ? (
<button
key={i}
onClick={() => onFilter({ actors: [c.name] })}
title={t("plex.info.filterActor", { name: c.name })}
onClick={() =>
onOpenPerson ? onOpenPerson(c.name) : onFilter?.({ actors: [c.name] })
}
title={
onOpenPerson
? t("plex.info.viewPerson", { name: c.name })
: t("plex.info.filterActor", { name: c.name })
}
className="group/cast w-20 shrink-0 text-center"
>
{inner}
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
//
// E5 S4: the actor detail page. Covers the two pieces the /code-review flagged as untested — the
// cast-row rewiring (a cast click now OPENS the person page via onOpenPerson, falling back to the old
// filter gesture only when no onOpenPerson is wired) and PlexPersonView's render (headshot/name hero,
// the Movies/Shows sections, and the "filter library by this person" action). i18n is key-passthrough
// and the api is mocked so the query resolves synchronously in the test.
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
const plexPerson = vi.fn();
const plexSetState = vi.fn((..._a: unknown[]) => Promise.resolve());
vi.mock("../lib/api", () => ({
api: {
plexPerson: (...a: unknown[]) => plexPerson(...a),
plexSetState: (...a: unknown[]) => plexSetState(...a),
},
EMPTY_PLEX_FILTERS: {},
plexFilterCount: () => 0,
}));
import { CastStrip, PlexPersonView } from "./PlexBrowse";
afterEach(cleanup);
const card = (id: string, title: string, type: "movie" | "show" = "movie") => ({
id,
type,
title,
year: 2000,
duration_seconds: 3600,
thumb: `/api/plex/image/${id}`,
playable: "direct",
status: "new",
position_seconds: 0,
});
function wrap(ui: React.ReactElement) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
describe("CastStrip cast click", () => {
it("opens the person page (onOpenPerson) rather than filtering, when both are wired", () => {
const onOpenPerson = vi.fn();
const onFilter = vi.fn();
render(
<CastStrip cast={[{ name: "Jane Doe" }]} onOpenPerson={onOpenPerson} onFilter={onFilter} />,
);
fireEvent.click(screen.getByTitle("plex.info.viewPerson"));
expect(onOpenPerson).toHaveBeenCalledWith("Jane Doe");
expect(onFilter).not.toHaveBeenCalled();
});
it("falls back to the filter gesture when only onFilter is wired (e.g. the overlay)", () => {
const onFilter = vi.fn();
render(<CastStrip cast={[{ name: "Jane Doe" }]} onFilter={onFilter} />);
fireEvent.click(screen.getByTitle("plex.info.filterActor"));
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
});
});
describe("PlexPersonView", () => {
it("renders the Movies + Shows sections and the filter-by-person action", async () => {
plexPerson.mockResolvedValue({
name: "Jane Doe",
photo: null,
movies: [card("m1", "Movie One")],
shows: [card("s1", "Show One", "show")],
});
const onFilter = vi.fn();
wrap(
<PlexPersonView
name="Jane Doe"
onBack={() => {}}
onOpenCard={() => {}}
onInfoCard={() => {}}
onFilter={onFilter}
/>,
);
// Both section grids render their cards once the mocked query resolves.
await screen.findByText("Movie One");
expect(screen.getByText("Show One")).toBeTruthy();
expect(screen.getByText("plex.person.movies")).toBeTruthy(); // section headings (key passthrough)
expect(screen.getByText("plex.person.shows")).toBeTruthy();
fireEvent.click(screen.getByText("plex.person.filterBy"));
expect(onFilter).toHaveBeenCalledWith({ actors: ["Jane Doe"] });
expect(plexPerson).toHaveBeenCalledWith("Jane Doe");
});
it("shows the empty message when the person has no titles", async () => {
plexPerson.mockResolvedValue({ name: "Nobody", photo: null, movies: [], shows: [] });
wrap(
<PlexPersonView
name="Nobody"
onBack={() => {}}
onOpenCard={() => {}}
onInfoCard={() => {}}
onFilter={() => {}}
/>,
);
await screen.findByText("plex.person.empty");
});
});
+7 -1
View File
@@ -2,6 +2,7 @@ import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState } fr
import { qk } from "../lib/queryKeys";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { plexLang } from "../i18n";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
import {
@@ -77,7 +78,12 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
tRef.current = t;
const qc = useQueryClient();
const [id, setId] = useState(itemId);
const detailQ = useQuery({ queryKey: qk.plexItem(id), queryFn: () => api.plexItem(id) });
// Serve the player's title/summary/rich detail in the active language (E5 S6), matching the grid.
const lang = plexLang();
const detailQ = useQuery({
queryKey: qk.plexItem(id, lang),
queryFn: () => api.plexItem(id, lang),
});
const detail = detailQ.data;
const duration = detail?.duration_seconds ?? 0;
+31 -4
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { parseSortState, type SortState } from "../lib/columnSort";
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useAccountRehydrate } from "../lib/useAccountRehydrate";
@@ -19,13 +20,19 @@ import { useAccountRehydrate } from "../lib/useAccountRehydrate";
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's
// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type.
export type PlexJumpTarget = { id: string; type: "movie" | "show" };
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
show: string;
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
filters: PlexFilters;
q: string;
field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use
playlistOpen: number | null;
jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title")
};
interface PlexActions {
@@ -34,7 +41,9 @@ interface PlexActions {
setSort: (v: SortState) => void;
setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void;
setField: (f: PlexSearchField) => void;
setPlaylistOpen: (id: number | null) => void;
setJumpTo: (t: PlexJumpTarget | null) => void;
}
const StateContext = createContext<PlexState>({
@@ -43,7 +52,9 @@ const StateContext = createContext<PlexState>({
sort: PLEX_SORT_FALLBACK,
filters: EMPTY_PLEX_FILTERS,
q: "",
field: "all",
playlistOpen: null,
jumpTo: null,
});
const ActionsContext = createContext<PlexActions>({
setScope: () => {},
@@ -51,7 +62,9 @@ const ActionsContext = createContext<PlexActions>({
setSort: () => {},
setFilters: () => {},
setQ: () => {},
setField: () => {},
setPlaylistOpen: () => {},
setJumpTo: () => {},
});
export function usePlex(): PlexState {
@@ -69,6 +82,7 @@ function readPlexScope(): string {
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField));
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
// upgrade rather than snapping back to the default.
@@ -118,7 +132,11 @@ export function PlexProvider({ children }: { children: ReactNode }) {
}, [filtersRaw]);
// Survives F5 (persisted), unlike the feed search — see the scope note above.
const [q, setQState] = useState<string>(readPlexQ);
const [field, setFieldState] = useState<PlexSearchField>(readPlexField);
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
// Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to;
// PlexBrowse consumes it into an info/show history subview then clears it.
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
// Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab).
const markDirty = useAccountRehydrate(() => {
@@ -127,6 +145,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
setSortState(readPlexSort());
setFiltersRaw(readPlexFiltersRaw());
setQState(readPlexQ());
setFieldState(readPlexField());
});
const setScope = useCallback(
@@ -170,14 +189,22 @@ export function PlexProvider({ children }: { children: ReactNode }) {
},
[markDirty],
);
const setField = useCallback(
(v: PlexSearchField) => {
markDirty();
setFieldState(v);
setAccountRaw(LS.plexField, v);
},
[markDirty],
);
const state = useMemo<PlexState>(
() => ({ scope, show, sort, filters, q, playlistOpen }),
[scope, show, sort, filters, q, playlistOpen],
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
[scope, show, sort, filters, q, field, playlistOpen, jumpTo],
);
const actions = useMemo<PlexActions>(
() => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }),
[setScope, setShow, setSort, setFilters, setQ],
() => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }),
[setScope, setShow, setSort, setFilters, setQ, setField],
);
return (
@@ -0,0 +1,50 @@
// @vitest-environment jsdom
//
// Contract of the header library-scope toggle (S3 relocation): it renders the three scopes, marks
// the current one, and writes the picked scope back to the provider. i18n is key-passthrough and the
// provider is mocked so the test drives scope directly; SegmentedToggle is a plain button group (no
// floating-ui), so no observer stubs are needed.
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
const plexState = { scope: "both" } as { scope: string };
const setScope = vi.fn();
vi.mock("./PlexProvider", () => ({
usePlex: () => plexState,
usePlexActions: () => ({ setScope }),
}));
import PlexScopeToggle from "./PlexScopeToggle";
beforeEach(() => {
plexState.scope = "both";
setScope.mockReset();
});
afterEach(cleanup);
describe("PlexScopeToggle", () => {
it("renders the three library scopes, current one pressed", () => {
plexState.scope = "movie";
render(<PlexScopeToggle />);
const group = screen.getByRole("group", { name: "plex.filter.scope" });
const btns = [...group.querySelectorAll("button")];
expect(btns.map((b) => b.textContent)).toEqual([
"plex.filter.scopeOpt.both",
"plex.filter.scopeOpt.movie",
"plex.filter.scopeOpt.show",
]);
const pressed = btns.filter((b) => b.getAttribute("aria-pressed") === "true");
expect(pressed.map((b) => b.textContent)).toEqual(["plex.filter.scopeOpt.movie"]);
});
it("writes the picked scope back to the provider", () => {
render(<PlexScopeToggle />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.scopeOpt.show" }));
expect(setScope).toHaveBeenCalledWith("show");
});
});
@@ -0,0 +1,26 @@
import { useTranslation } from "react-i18next";
import SegmentedToggle from "./SegmentedToggle";
import { usePlex, usePlexActions } from "./PlexProvider";
// The Plex library scope (both | movie | show) as a compact header control, sitting next to the
// search box (S3 toolbar relocation — it used to be a sidebar island). Reads/writes the same
// provider state the sidebar did, so scope stays in sync everywhere; reuses the shared
// SegmentedToggle and the existing plex.filter.scopeOpt.* labels.
const SCOPES = ["both", "movie", "show"] as const;
export default function PlexScopeToggle() {
const { t } = useTranslation();
const { scope } = usePlex();
const { setScope } = usePlexActions();
return (
<div className="pointer-events-auto shrink-0">
<SegmentedToggle
size="md"
ariaLabel={t("plex.filter.scope")}
options={SCOPES.map((s) => ({ value: s, label: t(`plex.filter.scopeOpt.${s}`) }))}
value={scope}
onChange={setScope}
/>
</div>
);
}
@@ -0,0 +1,203 @@
// @vitest-environment jsdom
//
// Contract of the Plex header search box (S2): the field scope-pill and the instant typeahead.
// Covers the pieces the /code-review flagged: the pill's per-scope options (Episode only in
// show/both), the suggestion list rendering, keyboard pick → jumpTo hand-off, and the regression
// that closing the panel (Escape) must NOT wedge it shut while the input keeps focus — typing again
// re-opens it. i18n is key-passthrough; the provider, api, and debounce are mocked so the test drives
// state directly. ActionMenu is floating-ui based, so we stub the observers and wrap in a FloatingTree.
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
});
// Identity debounce so a typed value is reflected immediately.
vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
// Controllable provider — PlexSearchBox reads q/scope/field and calls setQ/setField/setJumpTo.
const plexState = { scope: "both", field: "all", q: "" } as {
scope: string;
field: string;
q: string;
};
// setQ writes back to plexState.q so a typed value flows through to `dq` on the next render (the real
// provider persists it) — lets the reopen test assert a fresh query fires on retype, not just that
// the panel re-appears from cache.
const setQ = vi.fn((v: string) => {
plexState.q = v;
});
const setField = vi.fn();
const setJumpTo = vi.fn();
vi.mock("./PlexProvider", () => ({
usePlex: () => plexState,
usePlexActions: () => ({ setQ, setField, setJumpTo }),
}));
// api.plexLibrary stub — returns whatever the current test queued.
const plexLibrary = vi.fn();
vi.mock("../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
import PlexSearchBox from "./PlexSearchBox";
const card = (id: string, title: string, type: "movie" | "show" = "movie", year = 2000) => ({
id,
type,
title,
year,
thumb: `/api/plex/image/${id}`,
});
const result = (items: ReturnType<typeof card>[]) => ({
scope: "both",
total: items.length,
offset: 0,
limit: 10,
items,
episodes: [],
});
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
// jsdom has no layout, so scrollIntoView (used to keep the active option in view) is absent.
Element.prototype.scrollIntoView ??= () => {};
});
beforeEach(() => {
plexState.scope = "both";
plexState.field = "all";
plexState.q = "";
setQ.mockClear(); // clear calls but KEEP the write-back implementation
setField.mockReset();
setJumpTo.mockReset();
plexLibrary.mockReset();
plexLibrary.mockResolvedValue(result([]));
});
afterEach(cleanup);
const renderBox = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<FloatingTree>
<PlexSearchBox />
</FloatingTree>
</QueryClientProvider>,
);
};
const openPill = () =>
fireEvent.click(screen.getByRole("button", { name: "plex.searchField.aria" }));
describe("PlexSearchBox — scope-pill", () => {
it("offers all five field scopes when shows are in scope", () => {
plexState.scope = "both";
renderBox();
openPill();
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
expect(labels).toEqual([
"plex.searchField.all",
"plex.searchField.title",
"plex.searchField.people",
"plex.searchField.summary",
"plex.searchField.episode",
]);
});
it("hides the Episode scope in a movie-only scope", () => {
plexState.scope = "movie";
renderBox();
openPill();
const labels = screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
expect(labels).toEqual([
"plex.searchField.all",
"plex.searchField.title",
"plex.searchField.people",
"plex.searchField.summary",
]);
expect(labels).not.toContain("plex.searchField.episode");
});
});
describe("PlexSearchBox — typeahead", () => {
it("renders the top matches on focus and picks one via keyboard → jumpTo", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(
result([card("m1", "The Matrix"), card("m2", "The Matrix Reloaded")]),
);
renderBox();
fireEvent.focus(screen.getByRole("combobox"));
// Both matches show up as options.
await waitFor(() =>
expect(screen.getAllByRole("option").map((o) => o.textContent)).toEqual([
expect.stringContaining("The Matrix"),
expect.stringContaining("The Matrix Reloaded"),
]),
);
// The request carried the active field scope + the limit.
expect(plexLibrary).toHaveBeenCalledWith(
expect.objectContaining({ scope: "both", q: "matrix", field: "all", limit: 10 }),
);
// ArrowDown highlights the first, Enter opens it via the jumpTo hand-off (not a Go search).
const input = screen.getByRole("combobox");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie" });
});
it("reopens after Escape when the user types again (focus/dismiss are decoupled)", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
renderBox();
const input = screen.getByRole("combobox");
fireEvent.focus(input);
await screen.findByRole("listbox");
// Escape dismisses the panel (input stays focused — Escape does not blur).
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
// Typing again must re-open it without a blur+refocus round-trip AND fire a fresh suggest query
// for the new term (not just re-show the cached list).
plexLibrary.mockClear();
fireEvent.change(input, { target: { value: "matrixx" } });
await screen.findByRole("listbox");
await waitFor(() =>
expect(plexLibrary).toHaveBeenCalledWith(expect.objectContaining({ q: "matrixx" })),
);
});
it("marks the input as a combobox and toggles aria-expanded with the panel", async () => {
plexState.q = "matrix";
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix")]));
renderBox();
const input = screen.getByRole("combobox");
// Collapsed until focused.
expect(input.getAttribute("aria-expanded")).toBe("false");
expect(input.getAttribute("aria-controls")).toBeNull();
fireEvent.focus(input);
const list = await screen.findByRole("listbox");
// Expanded, and the input points at the listbox it controls.
expect(input.getAttribute("aria-expanded")).toBe("true");
expect(input.getAttribute("aria-controls")).toBe(list.id);
// Escape collapses it again and drops the association.
fireEvent.keyDown(input, { key: "Escape" });
await waitFor(() => expect(input.getAttribute("aria-expanded")).toBe("false"));
expect(input.getAttribute("aria-controls")).toBeNull();
});
});
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Film, Tv } from "lucide-react";
import SearchBar from "./SearchBar";
import ActionMenu from "./ActionMenu";
import { usePlex, usePlexActions } from "./PlexProvider";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { useDebounced } from "../lib/useDebounced";
import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/plexSearch";
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring —
// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses
// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the
// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview).
const MIN_CHARS = 2;
const SUGGEST_LIMIT = 10;
export default function PlexSearchBox() {
const { t } = useTranslation();
const { q, scope, field } = usePlex();
const { setQ, setField, setJumpTo } = usePlexActions();
const activeField = healPlexField(field, scope);
const dq = useDebounced(q.trim(), 200);
// `focused` mirrors the input's real DOM focus (only onFocus/onBlur touch it). `dismissed` is the
// separate "the user closed the panel" flag (Escape / after a pick) — kept apart so closing the
// panel never desyncs from actual focus: typing again clears `dismissed` and the panel re-opens
// without needing a blur+refocus cycle.
const [focused, setFocused] = useState(false);
const [dismissed, setDismissed] = useState(false);
const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none)
const listRef = useRef<HTMLDivElement>(null);
const listId = useId();
// The Episode scope surfaces episodes in the grid's grouped section, not as title cards, so the
// titles-only typeahead has nothing to show there — suppress it rather than show "no results".
const suggestEnabled =
focused && !dismissed && dq.length >= MIN_CHARS && activeField !== "episode";
const suggestQ = useQuery({
queryKey: qk.plexSuggest(scope, dq, activeField),
enabled: suggestEnabled,
queryFn: () => api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }),
staleTime: 30_000,
});
const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []) : [];
const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0);
// A fresh query resets the highlight.
useEffect(() => setHi(-1), [dq, activeField, scope]);
// Keep the highlighted option scrolled into view during arrow-key navigation.
useEffect(() => {
if (hi < 0) return;
listRef.current?.querySelector(`[data-idx="${hi}"]`)?.scrollIntoView({ block: "nearest" });
}, [hi]);
const pick = (i: number) => {
const c = suggestions[i];
if (!c) return;
setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" });
setDismissed(true); // close the panel; typing again re-opens it (see `dismissed`)
setHi(-1);
};
const fields = availablePlexFields(scope);
const pillLabel = t(plexFieldLabelKey(activeField));
// Only reference an option while the listbox is actually open — a dangling aria-activedescendant
// on a collapsed combobox is an inconsistent a11y state.
const activeOptionId = open && hi >= 0 ? `${listId}-opt-${hi}` : undefined;
return (
<SearchBar
value={q}
placeholder={t("plex.searchPlaceholder")}
// Typing after a dismiss (Escape / pick) re-opens the panel.
onChange={(v) => {
setDismissed(false);
setQ(v);
}}
onFocus={() => {
setFocused(true);
setDismissed(false);
}}
onBlur={() => setFocused(false)}
onKeyDown={(e) => {
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setHi((h) => Math.min(h + 1, suggestions.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHi((h) => Math.max(h - 1, -1));
} else if (e.key === "Enter" && hi >= 0) {
e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead
pick(hi);
} else if (e.key === "Escape") {
setDismissed(true);
setHi(-1); // don't leave a stale highlight to flash on the next reopen
}
}}
inputProps={{
role: "combobox",
"aria-autocomplete": "list",
"aria-expanded": open,
"aria-controls": open ? listId : undefined,
"aria-activedescendant": activeOptionId,
}}
leading={
<ActionMenu
ariaLabel={t("plex.searchField.aria")}
className="min-w-0 shrink inline-flex items-center gap-1 rounded-lg px-2 h-7 text-xs font-medium text-muted transition hover:text-fg hover:bg-border/50"
trigger={
<>
<span className="min-w-0 max-w-28 truncate">{pillLabel}</span>
<ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />
</>
}
items={fields.map((f) => ({
id: f,
label: t(plexFieldLabelKey(f)),
onSelect: () => setField(f),
}))}
/>
}
below={
open ? (
<div
ref={listRef}
id={listId}
role="listbox"
aria-label={t("plex.suggest.aria")}
className="glass-menu absolute left-0 right-0 top-full z-20 mt-2 max-h-[70vh] overflow-y-auto rounded-2xl p-1.5 animate-[popIn_0.16s_ease]"
>
{suggestions.length === 0 && suggestQ.isFetching && (
<div className="px-3 py-2 text-sm text-muted">{t("plex.loading")}</div>
)}
{suggestions.map((c, i) => (
<button
key={c.id}
id={`${listId}-opt-${i}`}
data-idx={i}
type="button"
role="option"
aria-selected={hi === i}
onMouseDown={(e) => e.preventDefault()} // keep input focus through the click
onMouseEnter={() => setHi(i)}
onClick={() => pick(i)}
className={`flex w-full items-center gap-3 rounded-xl p-1.5 text-left transition ${
hi === i ? "bg-border/60" : "hover:bg-border/40"
}`}
>
<img
src={c.thumb}
alt=""
loading="lazy"
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
/>
<span className="min-w-0 flex-1 truncate text-sm">{c.title}</span>
{c.year != null && (
<span className="shrink-0 text-xs text-muted tabular-nums">{c.year}</span>
)}
{c.type === "show" ? (
<Tv className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
) : (
<Film className="w-3.5 h-3.5 shrink-0 text-muted" aria-hidden="true" />
)}
</button>
))}
</div>
) : null
}
/>
);
}
+10 -49
View File
@@ -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<PlexFilters>) => 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: (
<SegmentedToggle
ariaLabel={t("plex.filter.scope")}
fullWidth
size="md"
options={(["both", "movie", "show"] as const).map((s) => ({
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: (
<SortControl
options={sortOptions}
sort={healedSort}
onChange={(next) => 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") },
@@ -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<typeof import("react-i18next")>();
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(<SearchBar value="" onChange={() => {}} 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");
});
});
+73 -35
View File
@@ -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<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
// Extra attributes spread onto the <input> (e.g. Plex's combobox ARIA). Spread FIRST so the
// component's own value/handlers always win.
inputProps?: InputHTMLAttributes<HTMLInputElement>;
}) {
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 (
<div className="pointer-events-auto flex-1 min-w-0 max-w-xl h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
<Search className="w-4 h-4 shrink-0 text-muted" />
<input
value={value}
onChange={(e) => 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 && (
<button
onClick={() => onChange("")}
title={t("header.clearSearch")}
aria-label={t("header.clearSearch")}
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
>
<X className="w-4 h-4" />
</button>
)}
{onGo && (
<button
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</button>
)}
<div className="pointer-events-auto relative flex-1 min-w-0 max-w-xl @min-[420px]/appmain:min-w-[15rem] @max-[640px]/appmain:order-last @max-[640px]/appmain:basis-full">
<div className="h-11 pl-3 pr-2 rounded-2xl glass-menu flex items-center gap-2 focus-within:outline-2 focus-within:outline-accent/60 focus-within:outline-offset-2">
{leading ?? <Search className="w-4 h-4 shrink-0 text-muted" />}
<input
{...inputProps}
value={value}
onChange={(e) => 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 && (
<button
onClick={() => onChange("")}
title={t("header.clearSearch")}
aria-label={t("header.clearSearch")}
className="shrink-0 p-0.5 rounded-full text-muted transition hover:text-fg hover:bg-border/60"
>
<X className="w-4 h-4" />
</button>
)}
{onGo && (
<button
onClick={fire}
disabled={goDisabled}
title={goTitle ?? goLabel ?? t("header.goTip")}
className="shrink-0 inline-flex items-center gap-1.5 rounded-xl bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:opacity-90 disabled:opacity-40 disabled:pointer-events-none"
>
{goIcon}
<span className={goIcon ? "hidden md:inline" : undefined}>
{goLabel ?? t("header.go")}
</span>
</button>
)}
</div>
{below}
</div>
);
}
@@ -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<typeof import("react-i18next")>();
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);
});
});
@@ -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<PlexFilters>) => 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;
}
+7
View File
@@ -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;
+19
View File
@@ -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."
}
}
+19
View File
@@ -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."
}
}
+52
View File
@@ -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<string, unknown> }
>;
function flatten(obj: Record<string, unknown>, 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<string, unknown>, key));
} else {
out.push(key);
}
}
return out.sort();
}
// { namespace: { en: {...}, hu: {...} } }
const byNs: Record<string, Record<string, Record<string, unknown>>> = {};
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: [] });
});
}
});
+27 -6
View File
@@ -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<PlexUnifiedResult> => {
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<PlexFacets> => {
// 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<PlexFacets> => {
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<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}`),
plexShow: (id: string, lang?: string): Promise<PlexShowDetail> =>
req(`/api/plex/show/${encodeURIComponent(id)}${lang === "hu" ? "?lang=hu" : ""}`),
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
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<PlexItemDetail> =>
req(`/api/plex/item/${encodeURIComponent(id)}`),
plexItem: (id: string, lang?: string): Promise<PlexItemDetail> =>
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<PlexPersonDetail> =>
req(`/api/plex/person/${encodeURIComponent(name)}`),
plexSession: (
id: string,
start = 0,
-2
View File
@@ -18,10 +18,8 @@ export interface PanelLayout {
const PANEL_ORDER: Record<PanelId, string[]> = {
feed: ["savedviews", "date", "channel", "tags", "language", "topic"],
plex: [
"scope",
"playlists",
"watch",
"sort",
"people",
"collection",
"rating",
+5 -1
View File
@@ -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() });
+22
View File
@@ -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();
});
});
+12
View File
@@ -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 };
}
+26
View File
@@ -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";
}
+9 -3
View File
@@ -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,
};
+16
View File
@@ -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",
+1
View File
@@ -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