release: 0.66.0 (e5-s3-toolbar-relocation)
This commit is contained in:
@@ -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")
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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("_", "\\_")
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+555
-523
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"] == []
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 ? "" : " | ||||