Merge: promote dev to prod
This commit is contained in:
commit
4e09dfc09b
20 changed files with 2061 additions and 675 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.36.4
|
0.37.0
|
||||||
|
|
|
||||||
91
backend/alembic/versions/0052_plex_show_meta.py
Normal file
91
backend/alembic/versions/0052_plex_show_meta.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Plex show-level filterable/orderable metadata
|
||||||
|
|
||||||
|
Revision ID: 0052_plex_show_meta
|
||||||
|
Revises: 0051_plex_link
|
||||||
|
Create Date: 2026-07-10
|
||||||
|
|
||||||
|
Gives TV shows the same filterable metadata movies already have (0045/0046), mirrored cheaply from
|
||||||
|
the Plex show section listing (no per-item calls): rating (audienceRating ~ IMDb), content rating,
|
||||||
|
studio/network, first-air date, and GIN-indexed genre / director / cast arrays + a `people_text`
|
||||||
|
blob folded into the generated `search_vector` (weight B). This lets the TV grid be filtered and
|
||||||
|
sorted like the movie grid. A generated column's expression can't be altered in place, so the
|
||||||
|
search_vector is dropped + recreated. All new columns are populated on the next Plex sync.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0052_plex_show_meta"
|
||||||
|
down_revision: Union[str, None] = "0051_plex_link"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
_CFG = "public.unaccent_simple"
|
||||||
|
_NEW_VECTOR = (
|
||||||
|
f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || "
|
||||||
|
f"setweight(to_tsvector('{_CFG}', coalesce(people_text, '')), 'B') || "
|
||||||
|
f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')"
|
||||||
|
)
|
||||||
|
_OLD_VECTOR = (
|
||||||
|
f"setweight(to_tsvector('{_CFG}', coalesce(title, '')), 'A') || "
|
||||||
|
f"setweight(to_tsvector('{_CFG}', left(coalesce(summary, ''), 1000)), 'C')"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_vector(expr: str) -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_plex_shows_search_vector")
|
||||||
|
op.execute("ALTER TABLE plex_shows DROP COLUMN search_vector")
|
||||||
|
op.execute(
|
||||||
|
f"ALTER TABLE plex_shows ADD COLUMN search_vector tsvector "
|
||||||
|
f"GENERATED ALWAYS AS ({expr}) STORED"
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX ix_plex_shows_search_vector ON plex_shows USING gin (search_vector)")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("plex_shows", sa.Column("rating", sa.Float(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("content_rating", sa.String(length=16), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("studio", sa.String(length=255), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("originally_available_at", sa.Date(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("genres", postgresql.JSONB(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("directors", postgresql.JSONB(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("cast_names", postgresql.JSONB(), nullable=True))
|
||||||
|
op.add_column("plex_shows", sa.Column("people_text", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
op.create_index("ix_plex_shows_rating", "plex_shows", ["rating"])
|
||||||
|
op.create_index("ix_plex_shows_content_rating", "plex_shows", ["content_rating"])
|
||||||
|
op.create_index("ix_plex_shows_studio", "plex_shows", ["studio"])
|
||||||
|
op.create_index("ix_plex_shows_originally_available_at", "plex_shows", ["originally_available_at"])
|
||||||
|
op.create_index("ix_plex_shows_genres_gin", "plex_shows", ["genres"], postgresql_using="gin")
|
||||||
|
op.create_index("ix_plex_shows_directors_gin", "plex_shows", ["directors"], postgresql_using="gin")
|
||||||
|
op.create_index("ix_plex_shows_cast_gin", "plex_shows", ["cast_names"], postgresql_using="gin")
|
||||||
|
# NB: ix_plex_shows_collections_gin already exists (created with collections in 0047).
|
||||||
|
|
||||||
|
_rebuild_vector(_NEW_VECTOR)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
_rebuild_vector(_OLD_VECTOR)
|
||||||
|
for ix in (
|
||||||
|
"ix_plex_shows_cast_gin",
|
||||||
|
"ix_plex_shows_directors_gin",
|
||||||
|
"ix_plex_shows_genres_gin",
|
||||||
|
"ix_plex_shows_originally_available_at",
|
||||||
|
"ix_plex_shows_studio",
|
||||||
|
"ix_plex_shows_content_rating",
|
||||||
|
"ix_plex_shows_rating",
|
||||||
|
):
|
||||||
|
op.drop_index(ix, table_name="plex_shows")
|
||||||
|
for col in (
|
||||||
|
"people_text",
|
||||||
|
"cast_names",
|
||||||
|
"directors",
|
||||||
|
"genres",
|
||||||
|
"originally_available_at",
|
||||||
|
"studio",
|
||||||
|
"content_rating",
|
||||||
|
"rating",
|
||||||
|
):
|
||||||
|
op.drop_column("plex_shows", col)
|
||||||
|
|
@ -956,9 +956,17 @@ class PlexLibrary(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
|
||||||
|
|
||||||
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page."""
|
"""A TV show (grandparent of episodes) — holds poster + summary for the drill-down page, plus
|
||||||
|
the same filterable metadata as movies (genres/rating/content_rating/studio/people) so the TV
|
||||||
|
grid can be filtered/sorted like the movie grid. All mirrored cheaply from the show listing."""
|
||||||
|
|
||||||
__tablename__ = "plex_shows"
|
__tablename__ = "plex_shows"
|
||||||
|
__table_args__ = (
|
||||||
|
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"),
|
||||||
|
# ix_plex_shows_collections_gin already created with collections (migration 0047).
|
||||||
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
|
|
@ -973,10 +981,20 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
child_count: Mapped[int | None] = mapped_column(Integer) # seasons
|
||||||
collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in
|
collection_keys: Mapped[list | None] = mapped_column(JSONB) # Plex collections this show is in
|
||||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
# Filterable / orderable metadata mirrored from the show section listing (parallels PlexItem).
|
||||||
|
rating: Mapped[float | None] = mapped_column(Float, index=True) # Plex audienceRating (~IMDb)
|
||||||
|
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||||
|
studio: Mapped[str | None] = mapped_column(String(255), index=True) # network/studio
|
||||||
|
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True) # first air date
|
||||||
|
genres: Mapped[list | None] = mapped_column(JSONB)
|
||||||
|
directors: Mapped[list | None] = mapped_column(JSONB)
|
||||||
|
cast_names: Mapped[list | None] = mapped_column(JSONB)
|
||||||
|
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
|
||||||
search_vector: Mapped[object | None] = mapped_column(
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
TSVECTOR,
|
TSVECTOR,
|
||||||
Computed(
|
Computed(
|
||||||
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
|
||||||
|
"setweight(to_tsvector('public.unaccent_simple', coalesce(people_text, '')), 'B') || "
|
||||||
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(summary, ''), 1000)), 'C')",
|
||||||
persisted=True,
|
persisted=True,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,18 @@ class PlexClient:
|
||||||
mc = self._get(f"/library/metadata/{rating_key}/children")
|
mc = self._get(f"/library/metadata/{rating_key}/children")
|
||||||
return mc.get("Metadata", []) or []
|
return mc.get("Metadata", []) or []
|
||||||
|
|
||||||
|
def related(self, rating_key: str) -> list[dict]:
|
||||||
|
"""Related / similar items for a movie or show, flattened out of Plex's Hub grouping.
|
||||||
|
Best-effort (needs the library's related data) — returns [] if Plex has none."""
|
||||||
|
try:
|
||||||
|
mc = self._get(f"/library/metadata/{rating_key}/related")
|
||||||
|
except PlexError:
|
||||||
|
return []
|
||||||
|
out: list[dict] = []
|
||||||
|
for hub in mc.get("Hub", []) or []:
|
||||||
|
out.extend(hub.get("Metadata", []) or [])
|
||||||
|
return out
|
||||||
|
|
||||||
def collections(self, section_key: str) -> list[dict]:
|
def collections(self, section_key: str) -> list[dict]:
|
||||||
"""All collections in a library section (title/summary/thumb/childCount/smart)."""
|
"""All collections in a library section (title/summary/thumb/childCount/smart)."""
|
||||||
mc = self._get(f"/library/sections/{section_key}/collections")
|
mc = self._get(f"/library/sections/{section_key}/collections")
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,16 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||||
sh.art_key = meta.get("art")
|
sh.art_key = meta.get("art")
|
||||||
sh.child_count = meta.get("childCount")
|
sh.child_count = meta.get("childCount")
|
||||||
sh.added_at = _epoch(meta.get("addedAt"))
|
sh.added_at = _epoch(meta.get("addedAt"))
|
||||||
|
# Filterable / orderable metadata — same cheap section-listing tags as movies.
|
||||||
|
sh.rating = _rating(meta)
|
||||||
|
sh.content_rating = (meta.get("contentRating") or None)
|
||||||
|
sh.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
|
||||||
|
sh.originally_available_at = _date(meta.get("originallyAvailableAt"))
|
||||||
|
sh.genres = _tags(meta, "Genre")
|
||||||
|
sh.directors = _tags(meta, "Director")
|
||||||
|
sh.cast_names = _tags(meta, "Role", limit=20)
|
||||||
|
people = list(dict.fromkeys((sh.directors or []) + (sh.cast_names or [])))
|
||||||
|
sh.people_text = " ".join(people) or None
|
||||||
stats["shows"] += 1
|
stats["shows"] += 1
|
||||||
db.flush()
|
db.flush()
|
||||||
show_id = {rk: sh.id for rk, sh in shows.items()}
|
show_id = {rk: sh.id for rk, sh in shows.items()}
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,51 @@ def push_state_to_plex(
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) -> None:
|
||||||
|
"""Coalesced Siftlode→Plex push for a whole-show / whole-season mark (see `_bulk_state`). Same
|
||||||
|
contract as `push_state_to_plex` (own DB session, best-effort, never raises) but ONE background
|
||||||
|
task for the entire batch: it reuses a single DB session and a single keep-alive PlexClient across
|
||||||
|
all `changes` instead of scheduling N tasks that each rebuild a session + HTTP client. Plex has no
|
||||||
|
batch-scrobble endpoint, so the per-item HTTP calls remain (looped over the shared connection), but
|
||||||
|
the O(N) task/session/client setup is gone. `changes` is a list of (item_id, rating_key, action)
|
||||||
|
with action ``watched`` (scrobble) or ``unwatched`` (unscrobble)."""
|
||||||
|
if not changes:
|
||||||
|
return
|
||||||
|
with SessionLocal() as db:
|
||||||
|
if link_for_push(db, user_id) is None:
|
||||||
|
return
|
||||||
|
synced_ids: list[int] = []
|
||||||
|
try:
|
||||||
|
with PlexClient(db) as plex:
|
||||||
|
for item_id, rating_key, action in changes:
|
||||||
|
try:
|
||||||
|
if action == "watched":
|
||||||
|
plex.scrobble(rating_key)
|
||||||
|
elif action == "unwatched":
|
||||||
|
plex.unscrobble(rating_key)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
except PlexError as e:
|
||||||
|
# One bad item mustn't abort the rest of the batch; leave its row dirty
|
||||||
|
# (synced_to_plex=False) for a later reconcile.
|
||||||
|
log.warning(
|
||||||
|
"Plex bulk push failed (user %s, item %s, %s): %s", user_id, item_id, action, e
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
synced_ids.append(item_id)
|
||||||
|
except PlexError as e:
|
||||||
|
# Couldn't even open the client — nothing pushed; everything stays dirty for reconcile.
|
||||||
|
log.warning("Plex bulk push could not connect (user %s): %s", user_id, e)
|
||||||
|
return
|
||||||
|
# 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.commit()
|
||||||
|
|
||||||
|
|
||||||
# --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------
|
# --- Phase C: incremental + full Plex ↔ Siftlode reconcile (scheduler jobs) ------------------------
|
||||||
|
|
||||||
# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just
|
# Clock-skew / round-trip slack when comparing a Plex timestamp to a Siftlode one. A state we just
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
@ -18,7 +21,7 @@ from urllib.parse import quote
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlalchemy import and_, func, or_, text
|
from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
|
|
@ -228,7 +231,7 @@ def _movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _show_card(sh: PlexShow) -> dict:
|
def _show_card(sh: PlexShow, status: str = "new") -> dict:
|
||||||
return {
|
return {
|
||||||
"id": sh.rating_key,
|
"id": sh.rating_key,
|
||||||
"type": "show",
|
"type": "show",
|
||||||
|
|
@ -236,9 +239,80 @@ def _show_card(sh: PlexShow) -> dict:
|
||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
"season_count": sh.child_count,
|
"season_count": sh.child_count,
|
||||||
|
# Aggregate watch-state across the show's episodes (new | in_progress | watched) → grid badge.
|
||||||
|
"status": status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _show_status(total: int, watched: int, inprog: int) -> str:
|
||||||
|
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
|
||||||
|
if total and watched >= total:
|
||||||
|
return "watched"
|
||||||
|
if (watched or 0) > 0 or (inprog or 0) > 0:
|
||||||
|
return "in_progress"
|
||||||
|
return "new"
|
||||||
|
|
||||||
|
|
||||||
|
def _rollup(cards: list[dict]) -> dict:
|
||||||
|
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
|
||||||
|
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
|
||||||
|
the episode count. Drives the show/season Resume + Play + mark-all buttons."""
|
||||||
|
total = len(cards)
|
||||||
|
watched = sum(1 for c in cards if c.get("status") == "watched")
|
||||||
|
inprog = [c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"]
|
||||||
|
resume = None
|
||||||
|
if inprog:
|
||||||
|
resume = inprog[-1] # continue the latest-started episode
|
||||||
|
else:
|
||||||
|
resume = next((c for c in cards if c.get("status") != "watched"), None)
|
||||||
|
return {
|
||||||
|
"status": _show_status(total, watched, len(inprog)),
|
||||||
|
"resume": resume,
|
||||||
|
"first": cards[0] if cards else None,
|
||||||
|
"episode_count": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _show_agg_subq(db: Session, user_id: int):
|
||||||
|
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
|
||||||
|
grid by aggregate watch-state and (b) compute each card's badge."""
|
||||||
|
ps = aliased(PlexState)
|
||||||
|
return (
|
||||||
|
db.query(
|
||||||
|
PlexItem.show_id.label("show_id"),
|
||||||
|
func.count(PlexItem.id).label("total"),
|
||||||
|
func.count(case((ps.status == "watched", 1))).label("watched"),
|
||||||
|
func.count(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
and_(ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched")),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).label("inprog"),
|
||||||
|
)
|
||||||
|
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
||||||
|
.filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
|
||||||
|
.group_by(PlexItem.show_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _show_state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
||||||
|
if not show_ids:
|
||||||
|
return {}
|
||||||
|
rows = _show_agg_subq(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all()
|
||||||
|
return {r.show_id: _show_status(r.total, r.watched, r.inprog) for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def _lib_key(db: Session, library_id: int | None) -> str | None:
|
||||||
|
"""The Plex section key for a library id — the info/show page passes it to the admin collection
|
||||||
|
editor (in the unified cross-library view there's no single library prop otherwise)."""
|
||||||
|
if library_id is None:
|
||||||
|
return None
|
||||||
|
lib = db.get(PlexLibrary, library_id)
|
||||||
|
return lib.plex_key if lib else None
|
||||||
|
|
||||||
|
|
||||||
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": e.rating_key,
|
"id": e.rating_key,
|
||||||
|
|
@ -280,11 +354,50 @@ def _added_cutoff(within: str | None) -> datetime | None:
|
||||||
return datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
return datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/browse")
|
def _meta_filter_conds(model, p: dict) -> list:
|
||||||
def browse(
|
"""The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for
|
||||||
library: str,
|
`p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a
|
||||||
|
list so faceted search can apply all-but-one group. DRY for browse, unified, and /facets."""
|
||||||
|
conds: list = []
|
||||||
|
gsel = _csv(p.get("genres"))
|
||||||
|
if gsel:
|
||||||
|
gc = [model.genres.contains([g]) for g in gsel]
|
||||||
|
conds.append(and_(*gc) if p.get("genre_mode") == "all" else or_(*gc))
|
||||||
|
crs = _csv(p.get("content_ratings"))
|
||||||
|
if crs:
|
||||||
|
conds.append(model.content_rating.in_(crs))
|
||||||
|
if p.get("year_min") is not None:
|
||||||
|
conds.append(model.year >= p["year_min"])
|
||||||
|
if p.get("year_max") is not None:
|
||||||
|
conds.append(model.year <= p["year_max"])
|
||||||
|
if p.get("rating_min") is not None:
|
||||||
|
conds.append(model.rating >= p["rating_min"])
|
||||||
|
cutoff = _added_cutoff(p.get("added_within"))
|
||||||
|
if cutoff is not None:
|
||||||
|
conds.append(model.added_at >= cutoff)
|
||||||
|
for d in _csv(p.get("directors")): # AND
|
||||||
|
conds.append(model.directors.contains([d]))
|
||||||
|
for a in _csv(p.get("actors")): # AND
|
||||||
|
conds.append(model.cast_names.contains([a]))
|
||||||
|
stds = _csv(p.get("studios"))
|
||||||
|
if stds: # OR
|
||||||
|
conds.append(model.studio.in_(stds))
|
||||||
|
if p.get("collection"):
|
||||||
|
conds.append(model.collection_keys.contains([p["collection"]]))
|
||||||
|
return conds
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_meta_filters(q, model, p: dict):
|
||||||
|
conds = _meta_filter_conds(model, p)
|
||||||
|
return q.filter(*conds) if conds else q
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/library")
|
||||||
|
def unified_library(
|
||||||
|
scope: str = "both",
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
sort: str = "added",
|
sort: str = "added",
|
||||||
|
sort_dir: str = "desc",
|
||||||
show: str = "all",
|
show: str = "all",
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
limit: int = Query(default=40, ge=1, le=100),
|
limit: int = Query(default=40, ge=1, le=100),
|
||||||
|
|
@ -301,120 +414,195 @@ def browse(
|
||||||
actors: str | None = None,
|
actors: str | None = None,
|
||||||
studios: str | None = None,
|
studios: str | None = None,
|
||||||
collection: str | None = None,
|
collection: str | None = None,
|
||||||
sort_dir: str = "desc",
|
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List a library's movies (leaves) or shows, with optional FTS search + sorting + a per-user
|
"""Unified cross-library browse: movies + shows in ONE mixed, paginated feed, scoped to
|
||||||
watch-state filter (`show`: all|unwatched|in_progress|watched — movie libraries only). Movie
|
movie|show|both, with the shared filters/search/watch-state (a show's state is the aggregate of
|
||||||
libraries return playable movie cards; show libraries return show cards (drill in via /show)."""
|
its episodes). On a search that also matches episodes (and shows are in scope), the matching
|
||||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section)."""
|
||||||
if lib is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Unknown Plex library")
|
|
||||||
offset = max(0, offset)
|
offset = max(0, offset)
|
||||||
model = PlexItem if lib.kind == "movie" else PlexShow
|
p = {
|
||||||
|
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
|
||||||
query = db.query(model)
|
"year_min": year_min, "year_max": year_max, "rating_min": rating_min,
|
||||||
if lib.kind == "movie":
|
"added_within": added_within, "directors": directors, "actors": actors,
|
||||||
query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
"studios": studios, "collection": collection,
|
||||||
# Per-user watch-state filter (movies only; a show isn't a single watch unit).
|
}
|
||||||
st = aliased(PlexState)
|
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
|
||||||
query = query.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
|
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
|
||||||
if show == "watched":
|
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
|
||||||
query = query.filter(st.status == "watched")
|
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
|
||||||
elif show == "in_progress":
|
want_shows = scope in ("show", "both") and bool(show_lib_ids)
|
||||||
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
|
|
||||||
elif show == "unwatched":
|
|
||||||
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
|
||||||
else: # all — hide only the explicitly hidden
|
|
||||||
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
|
||||||
# --- Metadata filters (movie libraries; @> containment is GIN-indexed) ---
|
|
||||||
gsel = _csv(genres)
|
|
||||||
if gsel:
|
|
||||||
conds = [PlexItem.genres.contains([g]) for g in gsel]
|
|
||||||
query = query.filter(and_(*conds) if genre_mode == "all" else or_(*conds))
|
|
||||||
crs = _csv(content_ratings)
|
|
||||||
if crs:
|
|
||||||
query = query.filter(PlexItem.content_rating.in_(crs))
|
|
||||||
if year_min is not None:
|
|
||||||
query = query.filter(PlexItem.year >= year_min)
|
|
||||||
if year_max is not None:
|
|
||||||
query = query.filter(PlexItem.year <= year_max)
|
|
||||||
if rating_min is not None:
|
|
||||||
query = query.filter(PlexItem.rating >= rating_min)
|
|
||||||
if duration_min is not None:
|
|
||||||
query = query.filter(PlexItem.duration_s >= duration_min)
|
|
||||||
if duration_max is not None:
|
|
||||||
query = query.filter(PlexItem.duration_s <= duration_max)
|
|
||||||
cutoff = _added_cutoff(added_within)
|
|
||||||
if cutoff is not None:
|
|
||||||
query = query.filter(PlexItem.added_at >= cutoff)
|
|
||||||
for d in _csv(directors): # AND — titles this whole set directed
|
|
||||||
query = query.filter(PlexItem.directors.contains([d]))
|
|
||||||
for a in _csv(actors): # AND — titles featuring all these people
|
|
||||||
query = query.filter(PlexItem.cast_names.contains([a]))
|
|
||||||
stds = _csv(studios)
|
|
||||||
if stds: # OR — a title has one studio
|
|
||||||
query = query.filter(PlexItem.studio.in_(stds))
|
|
||||||
else:
|
|
||||||
query = query.filter(PlexShow.library_id == lib.id)
|
|
||||||
|
|
||||||
# Collection filter applies to both movies and shows (both carry collection_keys).
|
|
||||||
if collection:
|
|
||||||
query = query.filter(model.collection_keys.contains([collection]))
|
|
||||||
|
|
||||||
ts = _to_tsquery_str(q) if q else None
|
ts = _to_tsquery_str(q) if q else None
|
||||||
tsq = None
|
tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None
|
||||||
if ts:
|
|
||||||
tsq = func.to_tsquery(_TS_CONFIG, ts)
|
|
||||||
query = query.filter(model.search_vector.op("@@")(tsq))
|
|
||||||
|
|
||||||
total = query.count()
|
selects = []
|
||||||
|
if want_movies:
|
||||||
|
st = aliased(PlexState)
|
||||||
|
m_status = case(
|
||||||
|
(st.status == "watched", "watched"),
|
||||||
|
(and_(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")), "in_progress"),
|
||||||
|
else_="new",
|
||||||
|
)
|
||||||
|
mq = (
|
||||||
|
db.query(
|
||||||
|
PlexItem.rating_key.label("rk"),
|
||||||
|
literal("movie").label("kind"),
|
||||||
|
PlexItem.title.label("title"),
|
||||||
|
PlexItem.year.label("year"),
|
||||||
|
PlexItem.rating.label("rating"),
|
||||||
|
PlexItem.added_at.label("added_at"),
|
||||||
|
PlexItem.originally_available_at.label("rel"),
|
||||||
|
PlexItem.duration_s.label("duration_s"),
|
||||||
|
PlexItem.playable.label("playable"),
|
||||||
|
cast(null(), Integer).label("season_count"),
|
||||||
|
func.coalesce(st.position_seconds, 0).label("position_seconds"),
|
||||||
|
m_status.label("status"),
|
||||||
|
(func.ts_rank(PlexItem.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"),
|
||||||
|
)
|
||||||
|
.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
|
||||||
|
.filter(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
|
||||||
|
)
|
||||||
|
if show == "watched":
|
||||||
|
mq = mq.filter(st.status == "watched")
|
||||||
|
elif show == "in_progress":
|
||||||
|
mq = mq.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
|
||||||
|
elif show == "unwatched":
|
||||||
|
mq = mq.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
||||||
|
else:
|
||||||
|
mq = mq.filter(or_(st.status.is_(None), st.status != "hidden"))
|
||||||
|
mq = _apply_meta_filters(mq, PlexItem, p)
|
||||||
|
if duration_min is not None:
|
||||||
|
mq = mq.filter(PlexItem.duration_s >= duration_min)
|
||||||
|
if duration_max is not None:
|
||||||
|
mq = mq.filter(PlexItem.duration_s <= duration_max)
|
||||||
|
if tsq is not None:
|
||||||
|
mq = mq.filter(PlexItem.search_vector.op("@@")(tsq))
|
||||||
|
selects.append(mq)
|
||||||
|
if want_shows:
|
||||||
|
agg = _show_agg_subq(db, user.id).subquery()
|
||||||
|
tot = func.coalesce(agg.c.total, 0)
|
||||||
|
wat = func.coalesce(agg.c.watched, 0)
|
||||||
|
inp = func.coalesce(agg.c.inprog, 0)
|
||||||
|
s_status = case(
|
||||||
|
(and_(tot > 0, wat >= tot), "watched"),
|
||||||
|
(or_(wat > 0, inp > 0), "in_progress"),
|
||||||
|
else_="new",
|
||||||
|
)
|
||||||
|
sq = (
|
||||||
|
db.query(
|
||||||
|
PlexShow.rating_key.label("rk"),
|
||||||
|
literal("show").label("kind"),
|
||||||
|
PlexShow.title.label("title"),
|
||||||
|
PlexShow.year.label("year"),
|
||||||
|
PlexShow.rating.label("rating"),
|
||||||
|
PlexShow.added_at.label("added_at"),
|
||||||
|
PlexShow.originally_available_at.label("rel"),
|
||||||
|
cast(null(), Integer).label("duration_s"),
|
||||||
|
cast(null(), String).label("playable"),
|
||||||
|
PlexShow.child_count.label("season_count"),
|
||||||
|
literal(0).label("position_seconds"),
|
||||||
|
s_status.label("status"),
|
||||||
|
(func.ts_rank(PlexShow.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"),
|
||||||
|
)
|
||||||
|
.outerjoin(agg, agg.c.show_id == PlexShow.id)
|
||||||
|
.filter(PlexShow.library_id.in_(show_lib_ids))
|
||||||
|
)
|
||||||
|
if show == "watched":
|
||||||
|
sq = sq.filter(tot > 0, wat >= tot)
|
||||||
|
elif show == "in_progress":
|
||||||
|
sq = sq.filter(or_(wat > 0, inp > 0), wat < tot)
|
||||||
|
elif show == "unwatched":
|
||||||
|
sq = sq.filter(wat == 0, inp == 0)
|
||||||
|
sq = _apply_meta_filters(sq, PlexShow, p)
|
||||||
|
if tsq is not None:
|
||||||
|
sq = sq.filter(PlexShow.search_vector.op("@@")(tsq))
|
||||||
|
selects.append(sq)
|
||||||
|
|
||||||
|
if not selects:
|
||||||
|
return {"scope": scope, "total": 0, "offset": offset, "limit": limit, "items": [], "episodes": []}
|
||||||
|
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
|
||||||
|
u = union.subquery()
|
||||||
|
total = db.query(func.count()).select_from(u).scalar() or 0
|
||||||
|
|
||||||
if tsq is not None:
|
if tsq is not None:
|
||||||
order = [func.ts_rank(model.search_vector, tsq).desc()]
|
order = [u.c.rank.desc()]
|
||||||
else:
|
else:
|
||||||
if sort == "title":
|
col = {
|
||||||
col = func.lower(model.title)
|
"title": func.lower(u.c.title),
|
||||||
elif sort == "year" and model is PlexItem:
|
"year": u.c.year,
|
||||||
col = PlexItem.year
|
"rating": u.c.rating,
|
||||||
elif sort == "rating" and model is PlexItem:
|
"duration": u.c.duration_s,
|
||||||
col = PlexItem.rating
|
"release": u.c.rel,
|
||||||
elif sort == "duration" and model is PlexItem:
|
}.get(sort, u.c.added_at)
|
||||||
col = PlexItem.duration_s
|
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
|
||||||
elif sort == "release" and model is PlexItem:
|
order.append(u.c.rk.desc())
|
||||||
col = PlexItem.originally_available_at
|
rows = db.query(u).order_by(*order).offset(offset).limit(limit).all()
|
||||||
else: # added
|
|
||||||
col = model.added_at
|
|
||||||
asc = sort_dir == "asc"
|
|
||||||
order = [(col.asc() if asc else col.desc()).nullslast()]
|
|
||||||
order.append(model.id.desc())
|
|
||||||
|
|
||||||
rows = query.order_by(*order).offset(offset).limit(limit).all()
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
if r.kind == "movie":
|
||||||
|
items.append({
|
||||||
|
"id": r.rk, "type": "movie", "title": r.title, "year": r.year,
|
||||||
|
"duration_seconds": r.duration_s, "thumb": f"/api/plex/image/{r.rk}",
|
||||||
|
"playable": r.playable, "status": r.status, "position_seconds": r.position_seconds,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
items.append({
|
||||||
|
"id": r.rk, "type": "show", "title": r.title, "year": r.year,
|
||||||
|
"thumb": f"/api/plex/image/{r.rk}", "season_count": r.season_count, "status": r.status,
|
||||||
|
})
|
||||||
|
|
||||||
if lib.kind == "movie":
|
episodes = []
|
||||||
by_item = {r.id: r for r in rows}
|
if tsq is not None and want_shows:
|
||||||
states = {
|
ep_st = aliased(PlexState)
|
||||||
s.item_id: s
|
eps = (
|
||||||
for s in db.query(PlexState).filter(
|
db.query(PlexItem, ep_st, PlexShow.title.label("show_title"))
|
||||||
PlexState.user_id == user.id, PlexState.item_id.in_(list(by_item.keys()) or [0])
|
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id))
|
||||||
|
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
|
||||||
|
.filter(
|
||||||
|
PlexItem.kind == "episode",
|
||||||
|
PlexItem.library_id.in_(show_lib_ids),
|
||||||
|
PlexItem.search_vector.op("@@")(tsq),
|
||||||
)
|
)
|
||||||
}
|
.order_by(func.ts_rank(PlexItem.search_vector, tsq).desc())
|
||||||
items = [_movie_card(r, states.get(r.id)) for r in rows]
|
.limit(24)
|
||||||
else:
|
.all()
|
||||||
items = [_show_card(r) for r in rows]
|
)
|
||||||
|
for e, s, show_title in eps:
|
||||||
|
card = _episode_card(e, s)
|
||||||
|
card["show_title"] = show_title
|
||||||
|
episodes.append(card)
|
||||||
|
|
||||||
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
|
return {"scope": scope, "total": total, "offset": offset, "limit": limit, "items": items, "episodes": episodes}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/facets")
|
@router.get("/facets")
|
||||||
def facets(
|
def facets(
|
||||||
library: str,
|
scope: str = "both",
|
||||||
|
show: str = "all",
|
||||||
|
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,
|
||||||
|
studios: str | None = None,
|
||||||
|
collection: str | None = None,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Available filter values for a movie library — genres + content ratings (with counts) and the
|
"""Available filter values for the unified library (scope movie|show|both), ADAPTED to the active
|
||||||
year/rating/duration bounds — so the sidebar only offers what the library actually contains."""
|
filters (faceted search): each group's values/bounds are computed with all the OTHER active
|
||||||
|
filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while
|
||||||
|
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
|
||||||
|
movie-only. (Search text is not factored in — facets track the sidebar filters.)"""
|
||||||
empty = {
|
empty = {
|
||||||
"genres": [],
|
"genres": [],
|
||||||
"content_ratings": [],
|
"content_ratings": [],
|
||||||
|
|
@ -424,33 +612,124 @@ def facets(
|
||||||
"duration_min": None,
|
"duration_min": None,
|
||||||
"duration_max": None,
|
"duration_max": None,
|
||||||
}
|
}
|
||||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
|
||||||
if lib is None or lib.kind != "movie":
|
movie_ids = [lb.id for lb in libs if lb.kind == "movie"] if scope in ("movie", "both") else []
|
||||||
|
show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else []
|
||||||
|
if not movie_ids and not show_ids:
|
||||||
return empty
|
return empty
|
||||||
base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'"
|
p = {
|
||||||
genres = db.execute(
|
"genres": genres, "genre_mode": genre_mode, "content_ratings": content_ratings,
|
||||||
text(
|
"year_min": year_min, "year_max": year_max, "rating_min": rating_min,
|
||||||
f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x "
|
"duration_min": duration_min, "duration_max": duration_max, "added_within": added_within,
|
||||||
"GROUP BY g ORDER BY c DESC, g"
|
"directors": directors, "actors": actors, "studios": studios, "collection": collection,
|
||||||
),
|
}
|
||||||
{"lib": lib.id},
|
|
||||||
).all()
|
uid = user.id
|
||||||
crs = db.execute(
|
|
||||||
text(f"SELECT content_rating, count(*) AS c {base} AND content_rating IS NOT NULL GROUP BY content_rating ORDER BY c DESC"),
|
def wsq(query, model, ids, pp, kind_movie, with_dur):
|
||||||
{"lib": lib.id},
|
"""Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a
|
||||||
).all()
|
facet aggregation query, so the facets reflect exactly the visible result set."""
|
||||||
b = db.execute(
|
query = query.filter(model.library_id.in_(ids), *_meta_filter_conds(model, pp))
|
||||||
text(f"SELECT min(year), max(year), max(rating), min(duration_s), max(duration_s) {base}"),
|
if kind_movie:
|
||||||
{"lib": lib.id},
|
query = query.filter(model.kind == "movie")
|
||||||
|
if with_dur and pp.get("duration_min") is not None:
|
||||||
|
query = query.filter(model.duration_s >= pp["duration_min"])
|
||||||
|
if with_dur and pp.get("duration_max") is not None:
|
||||||
|
query = query.filter(model.duration_s <= pp["duration_max"])
|
||||||
|
# Always join watch-state and mirror the grid's status branches (unified_library) exactly —
|
||||||
|
# hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
|
||||||
|
st = aliased(PlexState)
|
||||||
|
query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid))
|
||||||
|
if show == "watched":
|
||||||
|
query = query.filter(st.status == "watched")
|
||||||
|
elif show == "in_progress":
|
||||||
|
query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"))
|
||||||
|
elif show == "unwatched":
|
||||||
|
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
||||||
|
else:
|
||||||
|
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
||||||
|
else:
|
||||||
|
if show in ("watched", "in_progress", "unwatched"):
|
||||||
|
agg = _show_agg_subq(db, uid).subquery()
|
||||||
|
query = query.outerjoin(agg, agg.c.show_id == model.id)
|
||||||
|
tot = func.coalesce(agg.c.total, 0)
|
||||||
|
wat = func.coalesce(agg.c.watched, 0)
|
||||||
|
inp = func.coalesce(agg.c.inprog, 0)
|
||||||
|
if show == "watched":
|
||||||
|
query = query.filter(tot > 0, wat >= tot)
|
||||||
|
elif show == "in_progress":
|
||||||
|
query = query.filter(or_(wat > 0, inp > 0), wat < tot)
|
||||||
|
else:
|
||||||
|
query = query.filter(wat == 0, inp == 0)
|
||||||
|
return query
|
||||||
|
|
||||||
|
genre_counts: dict[str, int] = {}
|
||||||
|
cr_counts: dict[str, int] = {}
|
||||||
|
years: list[int] = []
|
||||||
|
ratings: list[float] = []
|
||||||
|
durs: list[int] = []
|
||||||
|
|
||||||
|
def per_table(model, ids, kind_movie: bool) -> None:
|
||||||
|
if not ids:
|
||||||
|
return
|
||||||
|
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
|
||||||
|
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
|
||||||
|
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
|
||||||
|
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
|
||||||
|
# every matching title carries them, so they stay togglable.)
|
||||||
|
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
|
||||||
|
expanded = wsq(
|
||||||
|
db.query(func.jsonb_array_elements_text(model.genres).label("g")),
|
||||||
|
model, ids, genre_pp, kind_movie, True,
|
||||||
|
).subquery()
|
||||||
|
for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all():
|
||||||
|
genre_counts[g] = genre_counts.get(g, 0) + c
|
||||||
|
# Content ratings — exclude its own.
|
||||||
|
crq = wsq(
|
||||||
|
db.query(model.content_rating, func.count()),
|
||||||
|
model, ids, {**p, "content_ratings": None}, kind_movie, True,
|
||||||
|
).filter(model.content_rating.isnot(None)).group_by(model.content_rating)
|
||||||
|
for cr, c in crq.all():
|
||||||
|
cr_counts[cr] = cr_counts.get(cr, 0) + c
|
||||||
|
# Year bounds — exclude the year filter.
|
||||||
|
by = wsq(
|
||||||
|
db.query(func.min(model.year), func.max(model.year)),
|
||||||
|
model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True,
|
||||||
).first()
|
).first()
|
||||||
|
if by[0] is not None:
|
||||||
|
years.append(by[0])
|
||||||
|
if by[1] is not None:
|
||||||
|
years.append(by[1])
|
||||||
|
# Rating max — exclude the rating filter.
|
||||||
|
br = wsq(db.query(func.max(model.rating)), model, ids, {**p, "rating_min": None}, kind_movie, True).scalar()
|
||||||
|
if br is not None:
|
||||||
|
ratings.append(float(br))
|
||||||
|
# Duration bounds (movies) — exclude the duration filter.
|
||||||
|
if kind_movie:
|
||||||
|
bd = wsq(
|
||||||
|
db.query(func.min(model.duration_s), func.max(model.duration_s)), model, ids, p, kind_movie, False
|
||||||
|
).first()
|
||||||
|
if bd[0] is not None:
|
||||||
|
durs.append(bd[0])
|
||||||
|
if bd[1] is not None:
|
||||||
|
durs.append(bd[1])
|
||||||
|
|
||||||
|
per_table(PlexItem, movie_ids, True)
|
||||||
|
per_table(PlexShow, show_ids, False)
|
||||||
|
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
|
||||||
|
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
|
||||||
|
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
|
||||||
|
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
|
||||||
|
for g in _csv(genres):
|
||||||
|
genre_counts.setdefault(g, 0)
|
||||||
return {
|
return {
|
||||||
"genres": [{"value": g, "count": c} for g, c in genres],
|
"genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])],
|
||||||
"content_ratings": [{"value": cr, "count": c} for cr, c in crs],
|
"content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])],
|
||||||
"year_min": b[0],
|
"year_min": min(years) if years else None,
|
||||||
"year_max": b[1],
|
"year_max": max(years) if years else None,
|
||||||
"rating_max": float(b[2]) if b[2] is not None else None,
|
"rating_max": max(ratings) if ratings else None,
|
||||||
"duration_min": b[3],
|
"duration_min": min(durs) if durs else None,
|
||||||
"duration_max": b[4],
|
"duration_max": max(durs) if durs else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -458,69 +737,6 @@ def _like_escape(s: str) -> str:
|
||||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/people")
|
|
||||||
def people(
|
|
||||||
q: str,
|
|
||||||
library: str,
|
|
||||||
user: User = Depends(current_user),
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
) -> dict:
|
|
||||||
"""Cast/crew whose name matches the search term — drives the virtual person cards shown above the
|
|
||||||
grid. Returns up to a few matches (most films first) with a count + a photo."""
|
|
||||||
term = (q or "").strip()
|
|
||||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
|
||||||
if lib is None or lib.kind != "movie" or len(term) < 2:
|
|
||||||
return {"people": []}
|
|
||||||
esc = _like_escape(term)
|
|
||||||
pfx, word = f"{esc}%", f"% {esc}%" # name starts with the term, or any of its words does
|
|
||||||
rows = db.execute(
|
|
||||||
text(
|
|
||||||
"SELECT name, kind, count(*) c FROM ("
|
|
||||||
" SELECT jsonb_array_elements_text(cast_names) AS name, 'actor' AS kind FROM plex_items"
|
|
||||||
" WHERE library_id = :lib AND kind = 'movie'"
|
|
||||||
" UNION ALL"
|
|
||||||
" SELECT jsonb_array_elements_text(directors) AS name, 'director' AS kind FROM plex_items"
|
|
||||||
" WHERE library_id = :lib AND kind = 'movie'"
|
|
||||||
") x WHERE name ILIKE :pfx OR name ILIKE :word "
|
|
||||||
"GROUP BY name, kind ORDER BY c DESC, name LIMIT 5"
|
|
||||||
),
|
|
||||||
{"lib": lib.id, "pfx": pfx, "word": word},
|
|
||||||
).all()
|
|
||||||
return {
|
|
||||||
"people": [
|
|
||||||
{"name": name, "kind": kind, "count": c, "photo": _person_photo(db, lib.id, name, kind)}
|
|
||||||
for name, kind, c in rows
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _person_photo(db: Session, lib_id: int, name: str, kind: str) -> str | None:
|
|
||||||
"""A person's headshot from one representative film's live metadata (image bytes are disk-cached)."""
|
|
||||||
col = PlexItem.directors if kind == "director" else PlexItem.cast_names
|
|
||||||
rk = (
|
|
||||||
db.query(PlexItem.rating_key)
|
|
||||||
.filter(PlexItem.library_id == lib_id, col.contains([name]))
|
|
||||||
.limit(1)
|
|
||||||
.scalar()
|
|
||||||
)
|
|
||||||
if not rk:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
with PlexClient(db) as plex:
|
|
||||||
meta = plex.metadata(rk) or {}
|
|
||||||
except (PlexError, PlexNotConfigured):
|
|
||||||
return None
|
|
||||||
for r in meta.get("Director" if kind == "director" else "Role") or []:
|
|
||||||
if r.get("tag") == name:
|
|
||||||
thumb = str(r.get("thumb") or "")
|
|
||||||
return (
|
|
||||||
f"/api/plex/person-image?u={quote(thumb, safe='')}"
|
|
||||||
if thumb.startswith(_PERSON_IMG_HOST)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _collection_editable(c: PlexCollection) -> bool:
|
def _collection_editable(c: PlexCollection) -> bool:
|
||||||
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
|
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
|
||||||
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
|
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
|
||||||
|
|
@ -543,16 +759,25 @@ def _collection_card(c: PlexCollection) -> dict:
|
||||||
|
|
||||||
@router.get("/collections")
|
@router.get("/collections")
|
||||||
def collections(
|
def collections(
|
||||||
library: str,
|
library: str | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""The library's mirrored collections as cards (most-populated first). Optional name filter `q`."""
|
"""Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a
|
||||||
|
`library` plex_key → that one library's collections (used by the collection editor). WITHOUT one →
|
||||||
|
the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single
|
||||||
|
library selected)."""
|
||||||
|
if library is not None:
|
||||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
||||||
if lib is None:
|
if lib is None:
|
||||||
return {"collections": []}
|
return {"collections": []}
|
||||||
query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id)
|
query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id)
|
||||||
|
else:
|
||||||
|
enabled_ids = [lb.id for lb in db.query(PlexLibrary.id).filter_by(enabled=True)]
|
||||||
|
if not enabled_ids:
|
||||||
|
return {"collections": []}
|
||||||
|
query = db.query(PlexCollection).filter(PlexCollection.library_id.in_(enabled_ids))
|
||||||
term = (q or "").strip()
|
term = (q or "").strip()
|
||||||
if term:
|
if term:
|
||||||
query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%"))
|
query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%"))
|
||||||
|
|
@ -921,6 +1146,42 @@ def reorder_playlist(pid: int, payload: dict, user: User = Depends(current_user)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# Live-Plex enrichment for a show (cast/IMDb + related) is user-independent raw payload, so it's cached
|
||||||
|
# per rating_key for a short TTL and shared across users; per-user shaping happens in the caller. Bounds
|
||||||
|
# staleness to the TTL while turning repeat opens of the same show into zero network calls.
|
||||||
|
_SHOW_ENRICH_TTL_S = 300
|
||||||
|
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
|
||||||
|
_show_enrich_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]:
|
||||||
|
"""(meta, related_raw) for a show's live Plex enrichment — served from the per-show TTL cache when
|
||||||
|
warm, otherwise fetched with the two Plex calls (metadata + related) run IN PARALLEL. Returns
|
||||||
|
({}, []) when Plex is unavailable/unconfigured (not cached, so it retries on the next open)."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with _show_enrich_lock:
|
||||||
|
hit = _show_enrich_cache.get(rating_key)
|
||||||
|
if hit is not None and hit[0] > now:
|
||||||
|
return hit[1], hit[2]
|
||||||
|
try:
|
||||||
|
with PlexClient(db) as plex:
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as ex:
|
||||||
|
meta_f = ex.submit(plex.metadata, rating_key)
|
||||||
|
related_f = ex.submit(plex.related, rating_key) # swallows its own errors → []
|
||||||
|
meta = meta_f.result() or {}
|
||||||
|
related_raw = related_f.result()
|
||||||
|
except (PlexError, PlexNotConfigured):
|
||||||
|
return {}, []
|
||||||
|
# Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a
|
||||||
|
# genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL
|
||||||
|
# even after Plex recovers. Skip the cache in that case (retry next open, as before); shows that DO
|
||||||
|
# have related items — the payload worth caching — still get cached.
|
||||||
|
if related_raw:
|
||||||
|
with _show_enrich_lock:
|
||||||
|
_show_enrich_cache[rating_key] = (now + _SHOW_ENRICH_TTL_S, meta, related_raw)
|
||||||
|
return meta, related_raw
|
||||||
|
|
||||||
|
|
||||||
@router.get("/show/{rating_key}")
|
@router.get("/show/{rating_key}")
|
||||||
def show_detail(
|
def show_detail(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|
@ -947,8 +1208,27 @@ def show_detail(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
by_season: dict[int | None, list] = {}
|
by_season: dict[int | None, list] = {}
|
||||||
|
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
|
||||||
for e in eps:
|
for e in eps:
|
||||||
by_season.setdefault(e.season_id, []).append(_episode_card(e, states.get(e.id)))
|
card = _episode_card(e, states.get(e.id))
|
||||||
|
by_season.setdefault(e.season_id, []).append(card)
|
||||||
|
all_cards.append(card)
|
||||||
|
show_roll = _rollup(all_cards)
|
||||||
|
|
||||||
|
# Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). The two live
|
||||||
|
# Plex calls are cached per-show + fetched in parallel by _show_enrichment; the per-user shaping
|
||||||
|
# (_related_show_cards adds this user's watch-state) stays out of the shared cache.
|
||||||
|
rich: dict = {}
|
||||||
|
related: list[dict] = []
|
||||||
|
try:
|
||||||
|
meta, related_raw = _show_enrichment(db, sh.rating_key)
|
||||||
|
rich = _rich_meta(meta)
|
||||||
|
related = _related_show_cards(db, user.id, related_raw, exclude=sh.id)
|
||||||
|
except Exception:
|
||||||
|
# Best-effort enrichment only — the local episode list is already assembled, so any live-Plex
|
||||||
|
# failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500.
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"show": {
|
"show": {
|
||||||
"id": sh.rating_key,
|
"id": sh.rating_key,
|
||||||
|
|
@ -957,18 +1237,70 @@ def show_detail(
|
||||||
"year": sh.year,
|
"year": sh.year,
|
||||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||||
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
||||||
|
"library": _lib_key(db, sh.library_id),
|
||||||
|
"content_rating": sh.content_rating,
|
||||||
|
"rating": sh.rating,
|
||||||
|
"genres": sh.genres or [],
|
||||||
|
"studio": sh.studio,
|
||||||
|
"season_count": sh.child_count,
|
||||||
|
"imdb_rating": rich.get("imdb_rating"),
|
||||||
|
"imdb_id": rich.get("imdb_id"),
|
||||||
|
"imdb_url": rich.get("imdb_url"),
|
||||||
|
"cast": rich.get("cast", []),
|
||||||
|
# Show-level rollup for the hero action buttons.
|
||||||
|
"status": show_roll["status"],
|
||||||
|
"resume": show_roll["resume"],
|
||||||
|
"first": show_roll["first"],
|
||||||
|
"episode_count": show_roll["episode_count"],
|
||||||
|
"collection_keys": sh.collection_keys or [],
|
||||||
},
|
},
|
||||||
"seasons": [
|
"seasons": [
|
||||||
{
|
_season_block(se, by_season.get(se.id, []))
|
||||||
|
for se in seasons
|
||||||
|
],
|
||||||
|
"related": related,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
||||||
|
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
||||||
|
season subpage, read from the same cached show-detail payload)."""
|
||||||
|
roll = _rollup(cards)
|
||||||
|
return {
|
||||||
"id": se.rating_key,
|
"id": se.rating_key,
|
||||||
"season_number": se.season_number,
|
"season_number": se.season_number,
|
||||||
"title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"),
|
"title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"),
|
||||||
"thumb": f"/api/plex/image/{se.rating_key}",
|
"thumb": f"/api/plex/image/{se.rating_key}",
|
||||||
"episodes": by_season.get(se.id, []),
|
"episode_count": roll["episode_count"],
|
||||||
|
"status": roll["status"],
|
||||||
|
"resume": roll["resume"],
|
||||||
|
"first": roll["first"],
|
||||||
|
"episodes": cards,
|
||||||
}
|
}
|
||||||
for se in seasons
|
|
||||||
],
|
|
||||||
|
def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: int) -> list[dict]:
|
||||||
|
"""Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable),
|
||||||
|
excluding the current show. Order preserved; capped."""
|
||||||
|
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
|
||||||
|
if not rks:
|
||||||
|
return []
|
||||||
|
shows = {
|
||||||
|
s.rating_key: s
|
||||||
|
for s in db.query(PlexShow).filter(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
|
||||||
}
|
}
|
||||||
|
statuses = _show_state_map(db, user_id, [s.id for s in shows.values()])
|
||||||
|
out: list[dict] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for rk in rks:
|
||||||
|
sh = shows.get(rk)
|
||||||
|
if sh is None or rk in seen:
|
||||||
|
continue
|
||||||
|
seen.add(rk)
|
||||||
|
out.append(_show_card(sh, statuses.get(sh.id, "new")))
|
||||||
|
if len(out) >= 20:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
|
def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
|
||||||
|
|
@ -1448,8 +1780,8 @@ def item_detail(
|
||||||
"text": codec not in _BITMAP_SUBS,
|
"text": codec not in _BITMAP_SUBS,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except (PlexError, PlexNotConfigured):
|
except Exception:
|
||||||
pass # metadata extras are best-effort; playback works without them
|
pass # metadata extras are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
|
||||||
|
|
||||||
prev_id, next_id = _episode_nav(db, it)
|
prev_id, next_id = _episode_nav(db, it)
|
||||||
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
|
||||||
|
|
@ -1463,6 +1795,7 @@ def item_detail(
|
||||||
"playable": it.playable,
|
"playable": it.playable,
|
||||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||||
|
"library": _lib_key(db, it.library_id),
|
||||||
"cast": rich.get("cast", []),
|
"cast": rich.get("cast", []),
|
||||||
"imdb_rating": rich.get("imdb_rating"),
|
"imdb_rating": rich.get("imdb_rating"),
|
||||||
"imdb_id": rich.get("imdb_id"),
|
"imdb_id": rich.get("imdb_id"),
|
||||||
|
|
@ -1609,6 +1942,82 @@ def item_state(
|
||||||
return {"status": status}
|
return {"status": status}
|
||||||
|
|
||||||
|
|
||||||
|
def _bulk_state(
|
||||||
|
background: BackgroundTasks, db: Session, user_id: int, eps: list[PlexItem], watched: bool
|
||||||
|
) -> int:
|
||||||
|
"""Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a
|
||||||
|
linked Plex account in the background (best-effort). Returns how many rows actually changed."""
|
||||||
|
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])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pushable = plex_watch.link_for_push(db, user_id) is not None
|
||||||
|
to_push: list[tuple[int, str, str]] = []
|
||||||
|
for e in eps:
|
||||||
|
st = existing.get(e.id)
|
||||||
|
prev = st.status if st is not None else "new"
|
||||||
|
if watched:
|
||||||
|
if st is None:
|
||||||
|
st = PlexState(user_id=user_id, item_id=e.id)
|
||||||
|
db.add(st)
|
||||||
|
st.status = "watched"
|
||||||
|
st.watched_at = now
|
||||||
|
st.position_seconds = 0
|
||||||
|
st.synced_to_plex = False
|
||||||
|
if prev != "watched":
|
||||||
|
to_push.append((e.id, e.rating_key, "watched"))
|
||||||
|
else: # → new: drop any state (but keep the user's private "hidden" marks)
|
||||||
|
if st is not None and prev != "hidden":
|
||||||
|
db.delete(st)
|
||||||
|
if prev != "new":
|
||||||
|
to_push.append((e.id, e.rating_key, "unwatched"))
|
||||||
|
db.commit()
|
||||||
|
if pushable and to_push:
|
||||||
|
# One coalesced background task for the whole batch (reuses a single session + Plex client),
|
||||||
|
# not one task per episode — a 200-ep show mark is 1 task, not 200.
|
||||||
|
background.add_task(plex_watch.push_bulk_state_to_plex, user_id, to_push)
|
||||||
|
return len(to_push)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/show/{rating_key}/state")
|
||||||
|
def show_state(
|
||||||
|
rating_key: str,
|
||||||
|
payload: dict,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
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()
|
||||||
|
if sh is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
||||||
|
watched = bool(payload.get("watched"))
|
||||||
|
eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all()
|
||||||
|
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||||
|
return {"changed": changed, "watched": watched}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/season/{rating_key}/state")
|
||||||
|
def season_state(
|
||||||
|
rating_key: str,
|
||||||
|
payload: dict,
|
||||||
|
background: BackgroundTasks,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
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()
|
||||||
|
if se is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown Plex season")
|
||||||
|
watched = bool(payload.get("watched"))
|
||||||
|
eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all()
|
||||||
|
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||||
|
return {"changed": changed, "watched": watched}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/stream/{rating_key}/session")
|
@router.post("/stream/{rating_key}/session")
|
||||||
def stream_session(
|
def stream_session(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|
|
||||||
|
|
@ -246,9 +246,12 @@ export default function App() {
|
||||||
const channelsView: ChannelsView =
|
const channelsView: ChannelsView =
|
||||||
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
|
||||||
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
|
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
|
||||||
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
|
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
|
||||||
|
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
|
||||||
|
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
|
||||||
|
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
|
||||||
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
||||||
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title");
|
||||||
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
|
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
|
||||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||||
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
|
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
|
||||||
|
|
@ -265,7 +268,7 @@ export default function App() {
|
||||||
// greeted you with a stale query (colliding with a persisted collection filter → confusing
|
// greeted you with a stale query (colliding with a persisted collection filter → confusing
|
||||||
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
|
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
|
||||||
// resets on reload.
|
// resets on reload.
|
||||||
const [plexQ, setPlexQ] = useState("");
|
const [plexQ, setPlexQ] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search
|
||||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
||||||
// there to see a specific set (the header's "without full history" link).
|
// there to see a specific set (the header's "without full history" link).
|
||||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||||
|
|
@ -740,8 +743,8 @@ export default function App() {
|
||||||
{page === "plex" && meQuery.data!.plex_enabled && (
|
{page === "plex" && meQuery.data!.plex_enabled && (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<PlexSidebar
|
<PlexSidebar
|
||||||
library={plexLib}
|
scope={plexScope}
|
||||||
setLibrary={setPlexLib}
|
setScope={setPlexScope}
|
||||||
show={plexShowFilter}
|
show={plexShowFilter}
|
||||||
setShow={setPlexShowFilter}
|
setShow={setPlexShowFilter}
|
||||||
sort={plexSort}
|
sort={plexSort}
|
||||||
|
|
@ -829,7 +832,8 @@ export default function App() {
|
||||||
<PlexBrowse
|
<PlexBrowse
|
||||||
q={plexQ}
|
q={plexQ}
|
||||||
onClearSearch={() => setPlexQ("")}
|
onClearSearch={() => setPlexQ("")}
|
||||||
library={plexLib}
|
scope={plexScope}
|
||||||
|
setScope={setPlexScope}
|
||||||
show={plexShowFilter}
|
show={plexShowFilter}
|
||||||
sort={plexSort}
|
sort={plexSort}
|
||||||
filters={plexFilters}
|
filters={plexFilters}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -50,7 +50,7 @@ export default function PlexCollectionEditor({
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
qc.invalidateQueries({ queryKey: ["plex-collections"] });
|
qc.invalidateQueries({ queryKey: ["plex-collections"] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-item", item.id] });
|
qc.invalidateQueries({ queryKey: ["plex-item", item.id] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||||
onChanged();
|
onChanged();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,9 @@
|
||||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||||
import {
|
|
||||||
Check,
|
|
||||||
ExternalLink,
|
|
||||||
FolderPlus,
|
|
||||||
ListPlus,
|
|
||||||
Play,
|
|
||||||
RotateCcw,
|
|
||||||
SlidersHorizontal,
|
|
||||||
Star,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||||
|
import { DetailCustomizeMenu, Filterable, PrefToggle, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
|
|
||||||
|
|
@ -38,26 +28,6 @@ type Props = {
|
||||||
library?: string;
|
library?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
|
|
||||||
function Filterable({
|
|
||||||
onClick,
|
|
||||||
title,
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
onClick?: () => void;
|
|
||||||
title?: string;
|
|
||||||
className?: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
if (!onClick) return <span className={className}>{children}</span>;
|
|
||||||
return (
|
|
||||||
<button onClick={onClick} title={title} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtDur(s?: number | null): string {
|
function fmtDur(s?: number | null): string {
|
||||||
if (!s) return "";
|
if (!s) return "";
|
||||||
const h = Math.floor(s / 3600);
|
const h = Math.floor(s / 3600);
|
||||||
|
|
@ -78,11 +48,12 @@ export default function PlexInfo({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
||||||
// Personal display prefs (default ON). Stored per-user; the backend merges any pref key.
|
// Personal display prefs (default ON), shared with the series detail pages via useDetailPrefs:
|
||||||
|
// artBg/showCast + their toggles and the raw savePref persister. The collection-strip source hiding
|
||||||
|
// is PlexInfo-specific state, persisted through the same savePref.
|
||||||
|
const { artBg, showCast, toggleArtBg, toggleCast, savePref } = useDetailPrefs();
|
||||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
||||||
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean; plexHiddenStripSources?: string[] };
|
{}) as { plexHiddenStripSources?: string[] };
|
||||||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
|
||||||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
|
||||||
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
|
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
|
||||||
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
|
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
|
||||||
const [hiddenSources, setHiddenSources] = useState<string[]>(
|
const [hiddenSources, setHiddenSources] = useState<string[]>(
|
||||||
|
|
@ -98,52 +69,16 @@ export default function PlexInfo({
|
||||||
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
|
const isAdmin = (qc.getQueryData<{ role?: string }>(["me"])?.role) === "admin";
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [playlistOpen, setPlaylistOpen] = useState(false);
|
const [playlistOpen, setPlaylistOpen] = useState(false);
|
||||||
const [customizing, setCustomizing] = useState(false);
|
|
||||||
const custBtnRef = useRef<HTMLButtonElement>(null);
|
|
||||||
const custMenuRef = useRef<HTMLDivElement>(null);
|
|
||||||
useDismiss(customizing, () => setCustomizing(false), [custMenuRef, custBtnRef]);
|
|
||||||
|
|
||||||
// Faint art backdrop, HTPC-style: paint the item's art as a FIXED background on the page's scroll
|
// Faint art backdrop, HTPC-style (page variant only) — the shared hook paints the item's art as a
|
||||||
// container (<main>) so it fills the content area and stays put while the info page scrolls over it
|
// fixed background on <main> and clears it on unmount / when disabled / in the overlay variant.
|
||||||
// (like the body's ambient pools). Scoped to <main> → never covers the nav/filter sidebars. A soft
|
useArtBackdrop(detail.art, variant !== "overlay" && artBg);
|
||||||
// scrim keeps text readable; kept subtle. Page variant only, toggleable, restored on unmount/off.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (variant === "overlay") return;
|
|
||||||
const main = document.querySelector("main");
|
|
||||||
if (!main) return;
|
|
||||||
const s = main.style;
|
|
||||||
const clear = () => {
|
|
||||||
s.backgroundImage = "";
|
|
||||||
s.backgroundSize = "";
|
|
||||||
s.backgroundPosition = "";
|
|
||||||
s.backgroundRepeat = "";
|
|
||||||
s.backgroundAttachment = "";
|
|
||||||
};
|
|
||||||
if (artBg && detail.art) {
|
|
||||||
s.backgroundImage =
|
|
||||||
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
|
|
||||||
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${detail.art}")`;
|
|
||||||
s.backgroundSize = "cover";
|
|
||||||
s.backgroundPosition = "center 20%";
|
|
||||||
s.backgroundRepeat = "no-repeat";
|
|
||||||
s.backgroundAttachment = "fixed";
|
|
||||||
} else {
|
|
||||||
clear();
|
|
||||||
}
|
|
||||||
return clear;
|
|
||||||
}, [variant, artBg, detail.art]);
|
|
||||||
const savePref = (patch: Record<string, unknown>) => {
|
|
||||||
api.savePrefs(patch).catch(() => {});
|
|
||||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
|
||||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
|
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
|
||||||
const setState = async (status: "new" | "watched") => {
|
const setState = async (status: "new" | "watched") => {
|
||||||
await api.plexSetState(detail.id, status).catch(() => {});
|
await api.plexSetState(detail.id, status).catch(() => {});
|
||||||
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
|
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||||
onStateChange?.();
|
onStateChange?.();
|
||||||
};
|
};
|
||||||
|
|
@ -180,38 +115,14 @@ export default function PlexInfo({
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative shrink-0">
|
<DetailCustomizeMenu
|
||||||
<button
|
overlay={overlay}
|
||||||
ref={custBtnRef}
|
artBg={artBg}
|
||||||
onClick={() => setCustomizing((v) => !v)}
|
onToggleArtBg={toggleArtBg}
|
||||||
title={t("plex.info.customize")}
|
showCast={showCast}
|
||||||
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
|
onToggleCast={toggleCast}
|
||||||
>
|
hasCast={detail.cast.length > 0}
|
||||||
<SlidersHorizontal className="w-4 h-4" />
|
extra={presentSources.map((src) => (
|
||||||
</button>
|
|
||||||
{customizing && (
|
|
||||||
<div
|
|
||||||
ref={custMenuRef}
|
|
||||||
className="glass-menu absolute right-0 top-full z-20 mt-1 w-52 rounded-xl p-1.5 text-sm"
|
|
||||||
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
|
||||||
>
|
|
||||||
<PrefToggle
|
|
||||||
label={t("plex.info.prefArtBg")}
|
|
||||||
on={artBg}
|
|
||||||
onClick={() => {
|
|
||||||
setArtBg((v) => !v);
|
|
||||||
savePref({ plexInfoArtBg: !artBg });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<PrefToggle
|
|
||||||
label={t("plex.info.prefCast")}
|
|
||||||
on={showCast}
|
|
||||||
onClick={() => {
|
|
||||||
setShowCast((v) => !v);
|
|
||||||
savePref({ plexInfoCast: !showCast });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{presentSources.map((src) => (
|
|
||||||
<PrefToggle
|
<PrefToggle
|
||||||
key={src}
|
key={src}
|
||||||
label={sourceLabel(src)}
|
label={sourceLabel(src)}
|
||||||
|
|
@ -219,9 +130,7 @@ export default function PlexInfo({
|
||||||
onClick={() => toggleSource(src)}
|
onClick={() => toggleSource(src)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{overlay && onClose && (
|
{overlay && onClose && (
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|
@ -534,21 +443,3 @@ export default function PlexInfo({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
|
|
||||||
>
|
|
||||||
<span>{label}</span>
|
|
||||||
<span
|
|
||||||
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -489,7 +489,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||||
old ? { ...old, position_seconds: pos } : old,
|
old ? { ...old, position_seconds: pos } : old,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
qc.invalidateQueries({ queryKey: ["plex-library"] });
|
||||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||||
};
|
};
|
||||||
}, [id, qc]);
|
}, [id, qc]);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
|
import { ChevronRight, Film, Layers, ListMusic, Plus, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||||
|
|
@ -12,8 +12,8 @@ import { useDebounced } from "../lib/useDebounced";
|
||||||
// backend so the sidebar only offers what the library actually contains.
|
// backend so the sidebar only offers what the library actually contains.
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
library: string;
|
scope: string; // movie | show | both (unified cross-library scope)
|
||||||
setLibrary: (v: string) => void;
|
setScope: (v: string) => void;
|
||||||
show: string;
|
show: string;
|
||||||
setShow: (v: string) => void;
|
setShow: (v: string) => void;
|
||||||
sort: string;
|
sort: string;
|
||||||
|
|
@ -27,7 +27,8 @@ type Props = {
|
||||||
|
|
||||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||||
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
||||||
const SHOW_SORTS = ["added", "title"] as const;
|
// Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime).
|
||||||
|
const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const;
|
||||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||||
// Duration buckets → [minSeconds|null, maxSeconds|null].
|
// Duration buckets → [minSeconds|null, maxSeconds|null].
|
||||||
|
|
@ -38,8 +39,8 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function PlexSidebar({
|
export default function PlexSidebar({
|
||||||
library,
|
scope,
|
||||||
setLibrary,
|
setScope,
|
||||||
show,
|
show,
|
||||||
setShow,
|
setShow,
|
||||||
sort,
|
sort,
|
||||||
|
|
@ -51,7 +52,6 @@ export default function PlexSidebar({
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
|
||||||
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
|
||||||
const [newPlaylist, setNewPlaylist] = useState("");
|
const [newPlaylist, setNewPlaylist] = useState("");
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
|
|
@ -63,35 +63,37 @@ export default function PlexSidebar({
|
||||||
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
qc.invalidateQueries({ queryKey: ["plex-playlists"] });
|
||||||
onOpenPlaylist(pl.id);
|
onOpenPlaylist(pl.id);
|
||||||
}
|
}
|
||||||
const libs = libsQ.data?.libraries ?? [];
|
// Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters
|
||||||
const activeLib = libs.find((l) => l.key === library);
|
// (faceted search) — so picking one filter narrows what the others offer. Refetches on any change.
|
||||||
const isMovieLib = activeLib?.kind === "movie";
|
// Key only on the fields plexFacets actually sends — sortDir/collectionTitle don't reach /facets,
|
||||||
|
// so keying on the whole `filters` object would re-run the heavy facet computation on every
|
||||||
|
// sort-direction toggle (or chip-title change) for an identical request.
|
||||||
|
const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters;
|
||||||
const facetsQ = useQuery({
|
const facetsQ = useQuery({
|
||||||
queryKey: ["plex-facets", library],
|
queryKey: ["plex-facets", scope, show, facetKey],
|
||||||
queryFn: () => api.plexFacets(library),
|
queryFn: () => api.plexFacets(scope, filters, show),
|
||||||
enabled: !!library && isMovieLib,
|
enabled: !!scope,
|
||||||
|
placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker)
|
||||||
});
|
});
|
||||||
const facets = facetsQ.data;
|
const facets = facetsQ.data;
|
||||||
|
|
||||||
// Collections picker (searchable; the list is only fetched when none is active).
|
// Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the
|
||||||
|
// unified scope has no single library). Only fetched while no collection is active (else the chip
|
||||||
|
// shows instead). Applying one sets the collection filter; the actual filtering already flows through
|
||||||
|
// /library + /facets by rating_key.
|
||||||
const [collSearch, setCollSearch] = useState("");
|
const [collSearch, setCollSearch] = useState("");
|
||||||
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
||||||
const collectionsQ = useQuery({
|
const collectionsQ = useQuery({
|
||||||
queryKey: ["plex-collections", library, collSearchDeb],
|
// "union" disambiguates from the editor's per-library ["plex-collections", <plex_key>] key (a search
|
||||||
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
|
// term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working.
|
||||||
enabled: !!library && isMovieLib && !filters.collection,
|
queryKey: ["plex-collections", "union", collSearchDeb],
|
||||||
|
queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined),
|
||||||
|
enabled: !filters.collection,
|
||||||
});
|
});
|
||||||
const collections = collectionsQ.data?.collections ?? [];
|
const collections = collectionsQ.data?.collections ?? [];
|
||||||
|
|
||||||
// Default to the first library once loaded (or if the stored one vanished).
|
const fCount = plexFilterCount(filters);
|
||||||
useEffect(() => {
|
const activeCount = fCount + (show !== "all" ? 1 : 0) + (sort !== "title" ? 1 : 0);
|
||||||
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
|
||||||
}, [libs, library, setLibrary]);
|
|
||||||
|
|
||||||
const fCount = isMovieLib ? plexFilterCount(filters) : 0;
|
|
||||||
const activeCount =
|
|
||||||
fCount + (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
|
||||||
const anyActive = activeCount > 0;
|
const anyActive = activeCount > 0;
|
||||||
|
|
||||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||||
|
|
@ -100,10 +102,14 @@ export default function PlexSidebar({
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
setFilters(EMPTY_PLEX_FILTERS);
|
setFilters(EMPTY_PLEX_FILTERS);
|
||||||
setShow("all");
|
setShow("all");
|
||||||
setSort("added");
|
setSort("title");
|
||||||
};
|
};
|
||||||
|
|
||||||
const sorts = isMovieLib ? MOVIE_SORTS : SHOW_SORTS;
|
// Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime).
|
||||||
|
// Ordered alphabetically by their localized label.
|
||||||
|
const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) =>
|
||||||
|
t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)),
|
||||||
|
);
|
||||||
const durBucketKey = DURATION_BUCKETS.find(
|
const durBucketKey = DURATION_BUCKETS.find(
|
||||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||||
)?.key;
|
)?.key;
|
||||||
|
|
@ -137,20 +143,19 @@ export default function PlexSidebar({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
|
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
|
||||||
{/* Library scope */}
|
{/* Scope — one unified library, filtered to movies, shows, or both. */}
|
||||||
<Section label={t("plex.filter.library")}>
|
<Section label={t("plex.filter.scope")}>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex gap-1">
|
||||||
{libs.map((l) => (
|
{(["both", "movie", "show"] as const).map((s) => (
|
||||||
<button
|
<button
|
||||||
key={l.key}
|
key={s}
|
||||||
onClick={() => setLibrary(l.key)}
|
onClick={() => setScope(s)}
|
||||||
className={`inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition ${
|
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
|
||||||
l.key === library ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{l.kind === "movie" ? <Film className="w-4 h-4" /> : <Tv2 className="w-4 h-4" />}
|
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
|
||||||
<span className="flex-1 text-left truncate">{l.title}</span>
|
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
|
||||||
<span className="opacity-60 text-xs">{l.count.toLocaleString()}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -205,8 +210,7 @@ export default function PlexSidebar({
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Watch state (movie libraries only) */}
|
{/* Watch state — movies per-title, shows aggregated across their episodes (0052) */}
|
||||||
{isMovieLib && (
|
|
||||||
<Section label={t("plex.filter.show")}>
|
<Section label={t("plex.filter.show")}>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
{SHOW_OPTS.map((s) => (
|
{SHOW_OPTS.map((s) => (
|
||||||
|
|
@ -216,7 +220,6 @@ export default function PlexSidebar({
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</ChipRow>
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sort + direction */}
|
{/* Sort + direction */}
|
||||||
<Section label={t("plex.filter.sort")}>
|
<Section label={t("plex.filter.sort")}>
|
||||||
|
|
@ -228,16 +231,16 @@ export default function PlexSidebar({
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</ChipRow>
|
||||||
<div className="mt-1.5 flex gap-1">
|
<div className="mt-1.5 flex gap-1">
|
||||||
{(["desc", "asc"] as const).map((d) => (
|
{(["asc", "desc"] as const).map((d) => (
|
||||||
<Chip key={d} active={(filters.sortDir ?? "desc") === d} onClick={() => patch({ sortDir: d })}>
|
<Chip key={d} active={(filters.sortDir ?? "asc") === d} onClick={() => patch({ sortDir: d })}>
|
||||||
{t(`plex.filter.dir.${d}`)}
|
{t(`plex.filter.dir.${d}`)}
|
||||||
</Chip>
|
</Chip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Metadata filters (movie libraries only) */}
|
{/* Metadata filters — movie AND show libraries (0052); the duration bucket is movie-only. */}
|
||||||
{isMovieLib && (
|
{(
|
||||||
<>
|
<>
|
||||||
{/* Active people / studios — set by clicking the info page (stackable). */}
|
{/* Active people / studios — set by clicking the info page (stackable). */}
|
||||||
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
|
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
|
||||||
|
|
@ -268,7 +271,9 @@ export default function PlexSidebar({
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
|
{/* Collection — the active one shows as a chip; otherwise a searchable picker over the union
|
||||||
|
of all enabled libraries' collections (also settable from an item info page's "Browse
|
||||||
|
collection"). */}
|
||||||
<Section label={t("plex.filter.collection")}>
|
<Section label={t("plex.filter.collection")}>
|
||||||
{filters.collection ? (
|
{filters.collection ? (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
|
@ -370,7 +375,8 @@ export default function PlexSidebar({
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Duration buckets */}
|
{/* Duration buckets — movie-only (a show has no single runtime) */}
|
||||||
|
{scope === "movie" && (
|
||||||
<Section label={t("plex.filter.duration")}>
|
<Section label={t("plex.filter.duration")}>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
<Chip
|
<Chip
|
||||||
|
|
@ -390,6 +396,7 @@ export default function PlexSidebar({
|
||||||
))}
|
))}
|
||||||
</ChipRow>
|
</ChipRow>
|
||||||
</Section>
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Added to Plex */}
|
{/* Added to Plex */}
|
||||||
<Section label={t("plex.filter.added")}>
|
<Section label={t("plex.filter.added")}>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"library": "Bibliothek",
|
"library": "Bibliothek",
|
||||||
|
"scope": "Bibliothek",
|
||||||
|
"scopeOpt": {
|
||||||
|
"both": "Alle",
|
||||||
|
"movie": "Filme",
|
||||||
|
"show": "Serien"
|
||||||
|
},
|
||||||
"show": "Anzeige",
|
"show": "Anzeige",
|
||||||
"showOpt": {
|
"showOpt": {
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
|
|
@ -63,20 +69,30 @@
|
||||||
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
"empty": "Noch nichts hier. Starte eine Plex-Synchronisierung auf der Admin-Konfigurationsseite.",
|
||||||
"loadMore": "Mehr laden",
|
"loadMore": "Mehr laden",
|
||||||
"watched": "Angesehen",
|
"watched": "Angesehen",
|
||||||
|
"inProgress": "Läuft",
|
||||||
"play": "Abspielen",
|
"play": "Abspielen",
|
||||||
"resume": "Fortsetzen",
|
"resume": "Fortsetzen",
|
||||||
"openShow": "Serie öffnen",
|
"openShow": "Serie öffnen",
|
||||||
"markWatched": "Als gesehen markieren",
|
"markWatched": "Als gesehen markieren",
|
||||||
"markUnwatched": "Als ungesehen markieren",
|
"markUnwatched": "Als ungesehen markieren",
|
||||||
"seasons": "{{count}} Staffeln",
|
"seasons": "{{count}} Staffeln",
|
||||||
"playerSoon": "Player kommt bald — „{{title}}“",
|
"unified": {
|
||||||
"people": {
|
"titles": "Titel",
|
||||||
"match": "Personen",
|
"episodes": "Folgen"
|
||||||
"actor": "Schauspieler",
|
|
||||||
"director": "Regie",
|
|
||||||
"count": "{{count}} Titel",
|
|
||||||
"added": "hinzugefügt"
|
|
||||||
},
|
},
|
||||||
|
"series": {
|
||||||
|
"seasons": "Staffeln",
|
||||||
|
"backToShow": "Zurück zur Serie",
|
||||||
|
"episodeCount": "{{count}} Folgen",
|
||||||
|
"playFromStart": "Von Anfang an",
|
||||||
|
"related": "Ähnliche Serien",
|
||||||
|
"markShowWatched": "Serie als gesehen",
|
||||||
|
"markShowUnwatched": "Serie als ungesehen",
|
||||||
|
"markSeasonWatched": "Staffel als gesehen",
|
||||||
|
"markSeasonUnwatched": "Staffel als ungesehen",
|
||||||
|
"addShowCollection": "Zur Sammlung"
|
||||||
|
},
|
||||||
|
"playerSoon": "Player kommt bald — „{{title}}“",
|
||||||
"collEditor": {
|
"collEditor": {
|
||||||
"manage": "Sammlungen",
|
"manage": "Sammlungen",
|
||||||
"title": "Sammlungen — {{title}}",
|
"title": "Sammlungen — {{title}}",
|
||||||
|
|
@ -158,6 +174,7 @@
|
||||||
"customize": "Ansicht anpassen",
|
"customize": "Ansicht anpassen",
|
||||||
"prefArtBg": "Dezenter Hintergrund",
|
"prefArtBg": "Dezenter Hintergrund",
|
||||||
"prefCast": "Besetzung",
|
"prefCast": "Besetzung",
|
||||||
|
"prefRelated": "Ähnliche Serien",
|
||||||
"stripSource": {
|
"stripSource": {
|
||||||
"collection": "Sammlungen",
|
"collection": "Sammlungen",
|
||||||
"imdb": "IMDb",
|
"imdb": "IMDb",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"library": "Library",
|
"library": "Library",
|
||||||
|
"scope": "Library",
|
||||||
|
"scopeOpt": {
|
||||||
|
"both": "All",
|
||||||
|
"movie": "Movies",
|
||||||
|
"show": "Shows"
|
||||||
|
},
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"showOpt": {
|
"showOpt": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
|
@ -63,20 +69,30 @@
|
||||||
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
"empty": "Nothing here yet. Run a Plex sync from the admin Config page.",
|
||||||
"loadMore": "Load more",
|
"loadMore": "Load more",
|
||||||
"watched": "Watched",
|
"watched": "Watched",
|
||||||
|
"inProgress": "In progress",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
"resume": "Resume",
|
"resume": "Resume",
|
||||||
"openShow": "Open show",
|
"openShow": "Open show",
|
||||||
"markWatched": "Mark watched",
|
"markWatched": "Mark watched",
|
||||||
"markUnwatched": "Mark unwatched",
|
"markUnwatched": "Mark unwatched",
|
||||||
"seasons": "{{count}} seasons",
|
"seasons": "{{count}} seasons",
|
||||||
"playerSoon": "Player coming soon — “{{title}}”",
|
"unified": {
|
||||||
"people": {
|
"titles": "Titles",
|
||||||
"match": "People",
|
"episodes": "Episodes"
|
||||||
"actor": "Actor",
|
|
||||||
"director": "Director",
|
|
||||||
"count": "{{count}} titles",
|
|
||||||
"added": "added"
|
|
||||||
},
|
},
|
||||||
|
"series": {
|
||||||
|
"seasons": "Seasons",
|
||||||
|
"backToShow": "Back to show",
|
||||||
|
"episodeCount": "{{count}} episodes",
|
||||||
|
"playFromStart": "Play from start",
|
||||||
|
"related": "Related shows",
|
||||||
|
"markShowWatched": "Mark show watched",
|
||||||
|
"markShowUnwatched": "Mark show unwatched",
|
||||||
|
"markSeasonWatched": "Mark season watched",
|
||||||
|
"markSeasonUnwatched": "Mark season unwatched",
|
||||||
|
"addShowCollection": "Add to collection"
|
||||||
|
},
|
||||||
|
"playerSoon": "Player coming soon — “{{title}}”",
|
||||||
"collEditor": {
|
"collEditor": {
|
||||||
"manage": "Collections",
|
"manage": "Collections",
|
||||||
"title": "Collections — {{title}}",
|
"title": "Collections — {{title}}",
|
||||||
|
|
@ -158,6 +174,7 @@
|
||||||
"customize": "Customize view",
|
"customize": "Customize view",
|
||||||
"prefArtBg": "Faint backdrop",
|
"prefArtBg": "Faint backdrop",
|
||||||
"prefCast": "Cast & crew",
|
"prefCast": "Cast & crew",
|
||||||
|
"prefRelated": "Related shows",
|
||||||
"stripSource": {
|
"stripSource": {
|
||||||
"collection": "Collections",
|
"collection": "Collections",
|
||||||
"imdb": "IMDb",
|
"imdb": "IMDb",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,12 @@
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"library": "Könyvtár",
|
"library": "Könyvtár",
|
||||||
|
"scope": "Könyvtár",
|
||||||
|
"scopeOpt": {
|
||||||
|
"both": "Mind",
|
||||||
|
"movie": "Filmek",
|
||||||
|
"show": "Sorozatok"
|
||||||
|
},
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
"showOpt": {
|
"showOpt": {
|
||||||
"all": "Mind",
|
"all": "Mind",
|
||||||
|
|
@ -63,20 +69,30 @@
|
||||||
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
"empty": "Még nincs itt semmi. Futtass egy Plex-szinkront az admin Konfiguráció oldalról.",
|
||||||
"loadMore": "Több betöltése",
|
"loadMore": "Több betöltése",
|
||||||
"watched": "Megnézve",
|
"watched": "Megnézve",
|
||||||
|
"inProgress": "Folyamatban",
|
||||||
"play": "Lejátszás",
|
"play": "Lejátszás",
|
||||||
"resume": "Folytatás",
|
"resume": "Folytatás",
|
||||||
"openShow": "Sorozat megnyitása",
|
"openShow": "Sorozat megnyitása",
|
||||||
"markWatched": "Megnézettnek jelöl",
|
"markWatched": "Megnézettnek jelöl",
|
||||||
"markUnwatched": "Nem-nézettnek jelöl",
|
"markUnwatched": "Nem-nézettnek jelöl",
|
||||||
"seasons": "{{count}} évad",
|
"seasons": "{{count}} évad",
|
||||||
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
|
"unified": {
|
||||||
"people": {
|
"titles": "Címek",
|
||||||
"match": "Személyek",
|
"episodes": "Epizódok"
|
||||||
"actor": "Színész",
|
|
||||||
"director": "Rendező",
|
|
||||||
"count": "{{count}} cím",
|
|
||||||
"added": "hozzáadva"
|
|
||||||
},
|
},
|
||||||
|
"series": {
|
||||||
|
"seasons": "Évadok",
|
||||||
|
"backToShow": "Vissza a sorozathoz",
|
||||||
|
"episodeCount": "{{count}} rész",
|
||||||
|
"playFromStart": "Lejátszás az elejéről",
|
||||||
|
"related": "Kapcsolódó sorozatok",
|
||||||
|
"markShowWatched": "Egész sorozat megnézve",
|
||||||
|
"markShowUnwatched": "Sorozat jelölés visszavonása",
|
||||||
|
"markSeasonWatched": "Egész évad megnézve",
|
||||||
|
"markSeasonUnwatched": "Évad jelölés visszavonása",
|
||||||
|
"addShowCollection": "Kollekcióhoz adás"
|
||||||
|
},
|
||||||
|
"playerSoon": "A lejátszó hamarosan jön — „{{title}}”",
|
||||||
"collEditor": {
|
"collEditor": {
|
||||||
"manage": "Kollekciók",
|
"manage": "Kollekciók",
|
||||||
"title": "Kollekciók — {{title}}",
|
"title": "Kollekciók — {{title}}",
|
||||||
|
|
@ -158,6 +174,7 @@
|
||||||
"customize": "Nézet testreszabása",
|
"customize": "Nézet testreszabása",
|
||||||
"prefArtBg": "Halvány háttér",
|
"prefArtBg": "Halvány háttér",
|
||||||
"prefCast": "Szereplők",
|
"prefCast": "Szereplők",
|
||||||
|
"prefRelated": "Kapcsolódó sorozatok",
|
||||||
"stripSource": {
|
"stripSource": {
|
||||||
"collection": "Kollekciók",
|
"collection": "Kollekciók",
|
||||||
"imdb": "IMDb",
|
"imdb": "IMDb",
|
||||||
|
|
|
||||||
|
|
@ -663,11 +663,25 @@ export interface PlexBrowseResult {
|
||||||
limit: number;
|
limit: number;
|
||||||
items: PlexCard[];
|
items: PlexCard[];
|
||||||
}
|
}
|
||||||
|
// Unified cross-library browse (movies + shows mixed). `episodes` is populated only on a search that
|
||||||
|
// also matches episodes (the grouped "Episodes" section).
|
||||||
|
export interface PlexUnifiedResult {
|
||||||
|
scope: string; // movie | show | both
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
items: PlexCard[];
|
||||||
|
episodes: PlexCard[];
|
||||||
|
}
|
||||||
export interface PlexSeasonDetail {
|
export interface PlexSeasonDetail {
|
||||||
id: string;
|
id: string; // season rating_key
|
||||||
season_number: number | null;
|
season_number: number | null;
|
||||||
title: string;
|
title: string;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
|
episode_count: number;
|
||||||
|
status: string; // aggregate: new | in_progress | watched
|
||||||
|
resume?: PlexCard | null; // on-deck episode (last in-progress, else first unwatched)
|
||||||
|
first?: PlexCard | null; // first episode (Play from the start)
|
||||||
episodes: PlexCard[];
|
episodes: PlexCard[];
|
||||||
}
|
}
|
||||||
export interface PlexShowDetail {
|
export interface PlexShowDetail {
|
||||||
|
|
@ -678,8 +692,24 @@ export interface PlexShowDetail {
|
||||||
year?: number | null;
|
year?: number | null;
|
||||||
thumb: string;
|
thumb: string;
|
||||||
art: string;
|
art: string;
|
||||||
|
library?: string | null; // Plex section key (for the admin collection editor)
|
||||||
|
content_rating?: string | null;
|
||||||
|
rating?: number | null;
|
||||||
|
genres: string[];
|
||||||
|
studio?: string | null;
|
||||||
|
season_count?: number | null;
|
||||||
|
imdb_rating?: number | null;
|
||||||
|
imdb_id?: string | null;
|
||||||
|
imdb_url?: string | null;
|
||||||
|
cast: PlexCastMember[];
|
||||||
|
status: string; // aggregate across all episodes
|
||||||
|
resume?: PlexCard | null;
|
||||||
|
first?: PlexCard | null;
|
||||||
|
episode_count: number;
|
||||||
|
collection_keys: string[];
|
||||||
};
|
};
|
||||||
seasons: PlexSeasonDetail[];
|
seasons: PlexSeasonDetail[];
|
||||||
|
related: PlexCard[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlexMarker {
|
export interface PlexMarker {
|
||||||
|
|
@ -697,6 +727,7 @@ export interface PlexItemDetail {
|
||||||
playable: string; // direct | remux | transcode
|
playable: string; // direct | remux | transcode
|
||||||
thumb: string;
|
thumb: string;
|
||||||
art: string;
|
art: string;
|
||||||
|
library?: string | null; // Plex section key (for the admin collection editor)
|
||||||
cast: PlexCastMember[];
|
cast: PlexCastMember[];
|
||||||
imdb_rating?: number | null;
|
imdb_rating?: number | null;
|
||||||
imdb_id?: string | null;
|
imdb_id?: string | null;
|
||||||
|
|
@ -789,11 +820,23 @@ export function plexFilterCount(f: PlexFilters): number {
|
||||||
(f.collection ? 1 : 0)
|
(f.collection ? 1 : 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export interface PlexPerson {
|
// Serialize the shared PlexFilters metadata fields onto a query string. Used by both the library grid
|
||||||
name: string;
|
// and the facets request so the two endpoints always agree on the filter set (paging/sort/sort_dir are
|
||||||
kind: "actor" | "director";
|
// each caller's own concern and stay out of here).
|
||||||
count: number;
|
export function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
|
||||||
photo?: string | null;
|
if (f.genres?.length) u.set("genres", f.genres.join(","));
|
||||||
|
if (f.genreMode === "all") u.set("genre_mode", "all");
|
||||||
|
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
|
||||||
|
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
|
||||||
|
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
|
||||||
|
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
|
||||||
|
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
|
||||||
|
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
|
||||||
|
if (f.addedWithin) u.set("added_within", f.addedWithin);
|
||||||
|
if (f.directors?.length) u.set("directors", f.directors.join(","));
|
||||||
|
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
||||||
|
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
||||||
|
if (f.collection) u.set("collection", f.collection);
|
||||||
}
|
}
|
||||||
export interface PlexFacets {
|
export interface PlexFacets {
|
||||||
genres: { value: string; count: number }[];
|
genres: { value: string; count: number }[];
|
||||||
|
|
@ -1145,16 +1188,16 @@ export const api = {
|
||||||
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
|
syncPlex: (): Promise<Record<string, unknown>> => req("/api/plex/sync", { method: "POST" }),
|
||||||
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
|
plexLibraries: (): Promise<{ enabled: boolean; libraries: PlexLibrary[] }> =>
|
||||||
req("/api/plex/libraries"),
|
req("/api/plex/libraries"),
|
||||||
plexBrowse: (p: {
|
plexLibrary: (p: {
|
||||||
library: string;
|
scope: string; // movie | show | both
|
||||||
q?: string;
|
q?: string;
|
||||||
sort?: string;
|
sort?: string;
|
||||||
show?: string;
|
show?: string;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
filters?: PlexFilters;
|
filters?: PlexFilters;
|
||||||
}): Promise<PlexBrowseResult> => {
|
}): Promise<PlexUnifiedResult> => {
|
||||||
const u = new URLSearchParams({ library: p.library });
|
const u = new URLSearchParams({ scope: p.scope });
|
||||||
if (p.q) u.set("q", p.q);
|
if (p.q) u.set("q", p.q);
|
||||||
if (p.sort) u.set("sort", p.sort);
|
if (p.sort) u.set("sort", p.sort);
|
||||||
if (p.show && p.show !== "all") u.set("show", p.show);
|
if (p.show && p.show !== "all") u.set("show", p.show);
|
||||||
|
|
@ -1162,29 +1205,28 @@ export const api = {
|
||||||
if (p.limit) u.set("limit", String(p.limit));
|
if (p.limit) u.set("limit", String(p.limit));
|
||||||
const f = p.filters;
|
const f = p.filters;
|
||||||
if (f) {
|
if (f) {
|
||||||
if (f.genres?.length) u.set("genres", f.genres.join(","));
|
appendPlexFilters(u, f);
|
||||||
if (f.genreMode === "all") u.set("genre_mode", "all");
|
u.set("sort_dir", f.sortDir ?? "asc"); // default ascending
|
||||||
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
|
|
||||||
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
|
|
||||||
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
|
|
||||||
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
|
|
||||||
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
|
|
||||||
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
|
|
||||||
if (f.addedWithin) u.set("added_within", f.addedWithin);
|
|
||||||
if (f.directors?.length) u.set("directors", f.directors.join(","));
|
|
||||||
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
|
||||||
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
|
||||||
if (f.collection) u.set("collection", f.collection);
|
|
||||||
if (f.sortDir === "asc") u.set("sort_dir", "asc");
|
|
||||||
}
|
}
|
||||||
return req(`/api/plex/browse?${u.toString()}`);
|
return req(`/api/plex/library?${u.toString()}`);
|
||||||
|
},
|
||||||
|
// Facets ADAPT to the active filters + watch-state (faceted search) — pass them so each group
|
||||||
|
// narrows the others and the bounds match the visible result set.
|
||||||
|
plexFacets: (scope: string, f?: PlexFilters, show?: string): Promise<PlexFacets> => {
|
||||||
|
const u = new URLSearchParams({ scope });
|
||||||
|
if (show && show !== "all") u.set("show", show);
|
||||||
|
if (f) appendPlexFilters(u, f);
|
||||||
|
return req(`/api/plex/facets?${u.toString()}`);
|
||||||
|
},
|
||||||
|
// With a library plex_key → that library's collections (editor). Without one → the union across all
|
||||||
|
// enabled libraries (the unified-scope sidebar picker).
|
||||||
|
plexCollections: (library?: string, q?: string): Promise<{ collections: PlexCollection[] }> => {
|
||||||
|
const u = new URLSearchParams();
|
||||||
|
if (library) u.set("library", library);
|
||||||
|
if (q) u.set("q", q);
|
||||||
|
const qs = u.toString();
|
||||||
|
return req(`/api/plex/collections${qs ? `?${qs}` : ""}`);
|
||||||
},
|
},
|
||||||
plexFacets: (library: string): Promise<PlexFacets> =>
|
|
||||||
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
|
||||||
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
|
|
||||||
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
|
||||||
plexCollections: (library: string, q?: string): Promise<{ collections: PlexCollection[] }> =>
|
|
||||||
req(`/api/plex/collections?library=${encodeURIComponent(library)}${q ? `&q=${encodeURIComponent(q)}` : ""}`),
|
|
||||||
// --- Collection editing (admin only; writes back to Plex) ---
|
// --- Collection editing (admin only; writes back to Plex) ---
|
||||||
plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise<PlexCollection> =>
|
plexCreateCollection: (library: string, title: string, item_rating_key: string): Promise<PlexCollection> =>
|
||||||
req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),
|
req(`/api/plex/collections`, { method: "POST", body: JSON.stringify({ library, title, item_rating_key }) }),
|
||||||
|
|
@ -1230,6 +1272,11 @@ export const api = {
|
||||||
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
|
req(`/api/plex/playlists/${id}/order`, { method: "PUT", body: JSON.stringify({ item_rating_keys: itemRks }) }),
|
||||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
|
// Mark a whole show / season watched or unwatched (all its episodes) for the current user.
|
||||||
|
plexShowState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
||||||
|
req(`/api/plex/show/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
|
||||||
|
plexSeasonState: (rk: string, watched: boolean): Promise<{ changed: number; watched: boolean }> =>
|
||||||
|
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||||
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
|
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
|
||||||
|
|
|
||||||
171
frontend/src/lib/plexDetailUi.tsx
Normal file
171
frontend/src/lib/plexDetailUi.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { SlidersHorizontal } from "lucide-react";
|
||||||
|
import { useDismiss } from "./useDismiss";
|
||||||
|
import { api } from "./api";
|
||||||
|
|
||||||
|
// Shared "detail page" UI reused by the movie info page (PlexInfo) and the series show/season pages,
|
||||||
|
// so they look consistent (the standing glassy/HTPC brief): the faint fixed art backdrop, the two
|
||||||
|
// per-user display prefs (art background / cast row — same keys as the movie info page), and the
|
||||||
|
// customize menu that toggles them.
|
||||||
|
|
||||||
|
export function useDetailPrefs() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ?? {}) as {
|
||||||
|
plexInfoArtBg?: boolean;
|
||||||
|
plexInfoCast?: boolean;
|
||||||
|
plexInfoRelated?: boolean;
|
||||||
|
};
|
||||||
|
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
||||||
|
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
||||||
|
const [showRelated, setShowRelated] = useState(prefs.plexInfoRelated !== false);
|
||||||
|
const save = (patch: Record<string, unknown>) => {
|
||||||
|
api.savePrefs(patch).catch(() => {});
|
||||||
|
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
||||||
|
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
artBg,
|
||||||
|
showCast,
|
||||||
|
showRelated,
|
||||||
|
savePref: save, // expose the raw persister for callers with extra per-user prefs (e.g. strip sources)
|
||||||
|
toggleArtBg: () => {
|
||||||
|
const next = !artBg;
|
||||||
|
setArtBg(next);
|
||||||
|
save({ plexInfoArtBg: next });
|
||||||
|
},
|
||||||
|
toggleCast: () => {
|
||||||
|
const next = !showCast;
|
||||||
|
setShowCast(next);
|
||||||
|
save({ plexInfoCast: next });
|
||||||
|
},
|
||||||
|
toggleRelated: () => {
|
||||||
|
const next = !showRelated;
|
||||||
|
setShowRelated(next);
|
||||||
|
save({ plexInfoRelated: next });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A metadata value that filters the grid when clicked (if onClick is wired), else plain text. Shared
|
||||||
|
// by the movie info page (PlexInfo) and the unified grid / show pages (PlexBrowse). The optional
|
||||||
|
// `title` gives a hover hint (e.g. "Filter by 1998"); callers that don't need it just omit it.
|
||||||
|
export function Filterable({
|
||||||
|
onClick,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick?: () => void;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
if (!onClick) return <span className={className}>{children}</span>;
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} title={title} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint the item's art as a faint FIXED backdrop on the page's <main> scroll container (HTPC-style),
|
||||||
|
// so the glass panels float over it. Cleared on unmount / when disabled / when there's no art.
|
||||||
|
export function useArtBackdrop(art: string | null | undefined, enabled: boolean) {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const main = document.querySelector("main");
|
||||||
|
if (!main) return;
|
||||||
|
const s = main.style;
|
||||||
|
const clear = () => {
|
||||||
|
s.backgroundImage = "";
|
||||||
|
s.backgroundSize = "";
|
||||||
|
s.backgroundPosition = "";
|
||||||
|
s.backgroundRepeat = "";
|
||||||
|
s.backgroundAttachment = "";
|
||||||
|
};
|
||||||
|
if (enabled && art) {
|
||||||
|
s.backgroundImage =
|
||||||
|
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
|
||||||
|
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${art}")`;
|
||||||
|
s.backgroundSize = "cover";
|
||||||
|
s.backgroundPosition = "center 20%";
|
||||||
|
s.backgroundRepeat = "no-repeat";
|
||||||
|
s.backgroundAttachment = "fixed";
|
||||||
|
} else {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
return clear;
|
||||||
|
}, [art, enabled]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailCustomizeMenu({
|
||||||
|
artBg,
|
||||||
|
onToggleArtBg,
|
||||||
|
showCast,
|
||||||
|
onToggleCast,
|
||||||
|
hasCast,
|
||||||
|
showRelated,
|
||||||
|
onToggleRelated,
|
||||||
|
hasRelated,
|
||||||
|
overlay,
|
||||||
|
extra,
|
||||||
|
}: {
|
||||||
|
artBg: boolean;
|
||||||
|
onToggleArtBg: () => void;
|
||||||
|
showCast: boolean;
|
||||||
|
onToggleCast: () => void;
|
||||||
|
hasCast: boolean;
|
||||||
|
showRelated?: boolean;
|
||||||
|
onToggleRelated?: () => void;
|
||||||
|
hasRelated?: boolean;
|
||||||
|
overlay?: boolean; // white-on-dark button styling when floated over the player
|
||||||
|
extra?: ReactNode; // additional toggles rendered after the built-in ones (e.g. strip-source buckets)
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDismiss(open, () => setOpen(false), [menuRef, btnRef]);
|
||||||
|
return (
|
||||||
|
<div className="relative shrink-0">
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
title={t("plex.info.customize")}
|
||||||
|
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="glass-menu absolute right-0 top-full z-20 mt-1 w-52 rounded-xl p-1.5 text-sm"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||||||
|
>
|
||||||
|
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
|
||||||
|
{hasCast && <PrefToggle label={t("plex.info.prefCast")} on={showCast} onClick={onToggleCast} />}
|
||||||
|
{hasRelated && onToggleRelated && (
|
||||||
|
<PrefToggle label={t("plex.info.prefRelated")} on={showRelated ?? true} onClick={onToggleRelated} />
|
||||||
|
)}
|
||||||
|
{extra}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}>
|
||||||
|
<span className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,25 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.37.0",
|
||||||
|
date: "2026-07-11",
|
||||||
|
summary: "Movies and shows in one library, a Plex-style series view, and smarter filters.",
|
||||||
|
features: [
|
||||||
|
"Movies and TV shows now share one unified library — browse them together and narrow to Both, Movies, or Shows from the sidebar.",
|
||||||
|
"A Plex-web-style series view: drill into a show's seasons and episodes, with quick watched toggles for a whole show, a whole season, or a single episode.",
|
||||||
|
"Filters are now adaptive — picking one narrows the others, so you're only offered options that still return results. In genre “All” (match-all) mode, only genres that actually co-occur with your current pick are shown.",
|
||||||
|
"The Collections filter is back in the sidebar as a searchable picker that spans all your libraries.",
|
||||||
|
"Series pages got a polish pass: season/episode quick actions and a subtle full-page art backdrop.",
|
||||||
|
],
|
||||||
|
fixes: [
|
||||||
|
"The grid now refreshes reliably after playback or a collection edit.",
|
||||||
|
"Filter counts and ranges no longer include hidden titles, so they match what you actually see.",
|
||||||
|
],
|
||||||
|
chores: [
|
||||||
|
"Faster whole-show/season “mark watched” and quicker show opening; internal cleanup of dead code and duplication.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.36.4",
|
version: "0.36.4",
|
||||||
date: "2026-07-10",
|
date: "2026-07-10",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export const LS = {
|
||||||
plexShow: "siftlode.plexShow",
|
plexShow: "siftlode.plexShow",
|
||||||
plexSort: "siftlode.plexSort",
|
plexSort: "siftlode.plexSort",
|
||||||
plexFilters: "siftlode.plexFilters",
|
plexFilters: "siftlode.plexFilters",
|
||||||
|
plexQ: "siftlode.plexQ",
|
||||||
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
plexPlaylistLayout: "siftlode.plexPlaylistLayout",
|
||||||
notifHistory: "siftlode.notifications",
|
notifHistory: "siftlode.notifications",
|
||||||
notifSettings: "siftlode.notifSettings",
|
notifSettings: "siftlode.notifSettings",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue