merge: Plex collections Phase 1 + info-page polish — v0.28.0
This commit is contained in:
commit
4b360f8f36
14 changed files with 542 additions and 37 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.27.0
|
0.28.0
|
||||||
57
backend/alembic/versions/0047_plex_collections.py
Normal file
57
backend/alembic/versions/0047_plex_collections.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Plex collections
|
||||||
|
|
||||||
|
Revision ID: 0047_plex_collections
|
||||||
|
Revises: 0046_plex_people_search
|
||||||
|
Create Date: 2026-07-06
|
||||||
|
|
||||||
|
Mirrors Plex collections locally so reads never hit Plex's slow (dual-language) collection queries.
|
||||||
|
A `plex_collections` table holds each collection's card metadata; membership is stored as GIN-indexed
|
||||||
|
`collection_keys` on the member movies (plex_items) and shows (plex_shows), so a collection can be a
|
||||||
|
combinable filter and a title's "collection strip" is a pure local lookup. `editable` marks the
|
||||||
|
(curated) collections we may later write back to Plex; everything is read-only by default.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "0047_plex_collections"
|
||||||
|
down_revision: Union[str, None] = "0046_plex_people_search"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"plex_collections",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("rating_key", sa.String(length=32), nullable=False),
|
||||||
|
sa.Column("library_id", sa.Integer(), sa.ForeignKey("plex_libraries.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=512), nullable=False),
|
||||||
|
sa.Column("summary", sa.Text(), nullable=True),
|
||||||
|
sa.Column("thumb_key", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("child_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("smart", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("editable", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("synced_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_plex_collections_rating_key", "plex_collections", ["rating_key"], unique=True)
|
||||||
|
op.create_index("ix_plex_collections_library_id", "plex_collections", ["library_id"])
|
||||||
|
|
||||||
|
op.add_column("plex_items", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
|
||||||
|
op.create_index("ix_plex_items_collections_gin", "plex_items", ["collection_keys"], postgresql_using="gin")
|
||||||
|
op.add_column("plex_shows", sa.Column("collection_keys", postgresql.JSONB(), nullable=True))
|
||||||
|
op.create_index("ix_plex_shows_collections_gin", "plex_shows", ["collection_keys"], postgresql_using="gin")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_plex_shows_collections_gin", table_name="plex_shows")
|
||||||
|
op.drop_column("plex_shows", "collection_keys")
|
||||||
|
op.drop_index("ix_plex_items_collections_gin", table_name="plex_items")
|
||||||
|
op.drop_column("plex_items", "collection_keys")
|
||||||
|
op.drop_index("ix_plex_collections_library_id", table_name="plex_collections")
|
||||||
|
op.drop_index("ix_plex_collections_rating_key", table_name="plex_collections")
|
||||||
|
op.drop_table("plex_collections")
|
||||||
|
|
@ -959,6 +959,7 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
thumb_key: Mapped[str | None] = mapped_column(String(512)) # Plex thumb path (proxied, not stored)
|
thumb_key: Mapped[str | None] = mapped_column(String(512)) # Plex thumb path (proxied, not stored)
|
||||||
art_key: Mapped[str | None] = mapped_column(String(512))
|
art_key: Mapped[str | None] = mapped_column(String(512))
|
||||||
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
|
||||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
search_vector: Mapped[object | None] = mapped_column(
|
search_vector: Mapped[object | None] = mapped_column(
|
||||||
TSVECTOR,
|
TSVECTOR,
|
||||||
|
|
@ -998,6 +999,7 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
|
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
|
||||||
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
|
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
|
||||||
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
|
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
|
||||||
|
Index("ix_plex_items_collections_gin", "collection_keys", postgresql_using="gin"),
|
||||||
)
|
)
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(primary_key=True)
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
|
@ -1044,6 +1046,8 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
genres: Mapped[list | None] = mapped_column(JSONB)
|
genres: Mapped[list | None] = mapped_column(JSONB)
|
||||||
directors: Mapped[list | None] = mapped_column(JSONB)
|
directors: Mapped[list | None] = mapped_column(JSONB)
|
||||||
cast_names: Mapped[list | None] = mapped_column(JSONB)
|
cast_names: Mapped[list | None] = mapped_column(JSONB)
|
||||||
|
# rating_keys of the Plex collections this movie belongs to (GIN — filter + card-strip siblings).
|
||||||
|
collection_keys: Mapped[list | None] = mapped_column(JSONB)
|
||||||
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
|
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
|
||||||
# finds titles by a person's name (and ranks them above summary-only mentions).
|
# finds titles by a person's name (and ranks them above summary-only mentions).
|
||||||
people_text: Mapped[str | None] = mapped_column(Text)
|
people_text: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
@ -1058,6 +1062,29 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
|
||||||
|
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
|
||||||
|
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
|
||||||
|
sync so reads never hit Plex's slow collection queries. Membership is stored as `collection_keys`
|
||||||
|
on the member movies (plex_items) / shows (plex_shows). `smart` = Plex's query-based flag;
|
||||||
|
`editable` = we may write changes back to Plex (default False = read-only; set for curated ones)."""
|
||||||
|
|
||||||
|
__tablename__ = "plex_collections"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
rating_key: Mapped[str] = mapped_column(String(32), unique=True, index=True)
|
||||||
|
library_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("plex_libraries.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(512))
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text)
|
||||||
|
thumb_key: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
child_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
smart: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
editable: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
class PlexState(Base, UpdatedAtMixin):
|
class PlexState(Base, UpdatedAtMixin):
|
||||||
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
|
"""Per-user watch state for a Plex item — mirrors VideoState. Lives in Siftlode's own DB so
|
||||||
it works for users WITHOUT a Plex account (the whole point of the access-layer design).
|
it works for users WITHOUT a Plex account (the whole point of the access-layer design).
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,16 @@ 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 collections(self, section_key: str) -> list[dict]:
|
||||||
|
"""All collections in a library section (title/summary/thumb/childCount/smart)."""
|
||||||
|
mc = self._get(f"/library/sections/{section_key}/collections")
|
||||||
|
return mc.get("Metadata", []) or []
|
||||||
|
|
||||||
|
def collection_children(self, rating_key: str) -> list[dict]:
|
||||||
|
"""The member items (movies / shows) of a collection."""
|
||||||
|
mc = self._get(f"/library/collections/{rating_key}/children")
|
||||||
|
return mc.get("Metadata", []) or []
|
||||||
|
|
||||||
def metadata(self, rating_key: str, markers: bool = False) -> dict | None:
|
def metadata(self, rating_key: str, markers: bool = False) -> dict | None:
|
||||||
"""Full metadata for one item; include intro/credit markers when requested."""
|
"""Full metadata for one item; include intro/credit markers when requested."""
|
||||||
params = {"includeMarkers": 1} if markers else None
|
params = {"includeMarkers": 1} if markers else None
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from datetime import datetime, timezone
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
|
|
||||||
log = logging.getLogger("siftlode.plex")
|
log = logging.getLogger("siftlode.plex")
|
||||||
|
|
@ -111,7 +111,7 @@ def sync(db: Session) -> dict:
|
||||||
"""Full reconcile of the enabled movie/show sections. Returns a small stats dict."""
|
"""Full reconcile of the enabled movie/show sections. Returns a small stats dict."""
|
||||||
if not sysconfig.get_bool(db, "plex_enabled"):
|
if not sysconfig.get_bool(db, "plex_enabled"):
|
||||||
return {"skipped": "disabled"}
|
return {"skipped": "disabled"}
|
||||||
stats = {"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0}
|
stats = {"libraries": 0, "movies": 0, "shows": 0, "seasons": 0, "episodes": 0, "collections": 0}
|
||||||
wanted = _enabled_section_keys(db)
|
wanted = _enabled_section_keys(db)
|
||||||
try:
|
try:
|
||||||
with PlexClient(db) as plex:
|
with PlexClient(db) as plex:
|
||||||
|
|
@ -127,6 +127,7 @@ def sync(db: Session) -> dict:
|
||||||
_sync_movies(db, plex, lib, stats)
|
_sync_movies(db, plex, lib, stats)
|
||||||
else:
|
else:
|
||||||
_sync_shows(db, plex, lib, stats)
|
_sync_shows(db, plex, lib, stats)
|
||||||
|
_sync_collections(db, plex, lib, stats)
|
||||||
db.commit()
|
db.commit()
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured as e:
|
||||||
return {"skipped": str(e)}
|
return {"skipped": str(e)}
|
||||||
|
|
@ -268,3 +269,43 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) ->
|
||||||
row.episode_number = meta.get("index")
|
row.episode_number = meta.get("index")
|
||||||
stats["episodes"] += 1
|
stats["episodes"] += 1
|
||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None:
|
||||||
|
"""Mirror a library's collections + membership. Reads never touch Plex afterwards. Membership is
|
||||||
|
written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile:
|
||||||
|
collections gone from Plex are pruned, and each member row's keys are rebuilt from scratch."""
|
||||||
|
cols = plex.collections(lib.plex_key)
|
||||||
|
existing = {c.rating_key: c for c in db.query(PlexCollection).filter_by(library_id=lib.id)}
|
||||||
|
seen: set[str] = set()
|
||||||
|
membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...]
|
||||||
|
for meta in cols:
|
||||||
|
rk = str(meta.get("ratingKey") or "")
|
||||||
|
if not rk:
|
||||||
|
continue
|
||||||
|
col = existing.get(rk) or PlexCollection(rating_key=rk)
|
||||||
|
if col.id is None:
|
||||||
|
db.add(col)
|
||||||
|
col.library_id = lib.id
|
||||||
|
col.title = (meta.get("title") or "").strip() or "Untitled"
|
||||||
|
col.summary = meta.get("summary")
|
||||||
|
col.thumb_key = meta.get("thumb")
|
||||||
|
col.child_count = meta.get("childCount")
|
||||||
|
col.smart = str(meta.get("smart")) == "1" # `editable` is preserved (user-managed, not here)
|
||||||
|
col.synced_at = datetime.now(timezone.utc)
|
||||||
|
seen.add(rk)
|
||||||
|
for child in plex.collection_children(rk):
|
||||||
|
crk = str(child.get("ratingKey") or "")
|
||||||
|
if crk:
|
||||||
|
membership.setdefault(crk, []).append(rk)
|
||||||
|
stats["collections"] += 1
|
||||||
|
db.flush()
|
||||||
|
for rk, col in existing.items():
|
||||||
|
if rk not in seen:
|
||||||
|
db.delete(col)
|
||||||
|
# Rebuild collection_keys on every member row of this library (clear + set).
|
||||||
|
for row in db.query(PlexItem).filter_by(library_id=lib.id):
|
||||||
|
row.collection_keys = membership.get(row.rating_key) or None
|
||||||
|
for row in db.query(PlexShow).filter_by(library_id=lib.id):
|
||||||
|
row.collection_keys = membership.get(row.rating_key) or None
|
||||||
|
db.flush()
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ from app import sysconfig
|
||||||
from app.auth import current_user
|
from app.auth import current_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||||
from app.plex import paths as plex_paths
|
from app.plex import paths as plex_paths
|
||||||
from app.plex import stream as plex_stream
|
from app.plex import stream as plex_stream
|
||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
|
|
@ -204,6 +204,7 @@ def browse(
|
||||||
directors: str | None = None,
|
directors: str | None = None,
|
||||||
actors: str | None = None,
|
actors: str | None = None,
|
||||||
studios: str | None = None,
|
studios: str | None = None,
|
||||||
|
collection: str | None = None,
|
||||||
sort_dir: str = "desc",
|
sort_dir: str = "desc",
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
|
|
@ -262,6 +263,10 @@ def browse(
|
||||||
else:
|
else:
|
||||||
query = query.filter(PlexShow.library_id == lib.id)
|
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 = None
|
||||||
if ts:
|
if ts:
|
||||||
|
|
@ -420,6 +425,39 @@ def _person_photo(db: Session, lib_id: int, name: str, kind: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _collection_card(c: PlexCollection) -> dict:
|
||||||
|
return {
|
||||||
|
"id": c.rating_key,
|
||||||
|
"title": c.title,
|
||||||
|
"summary": c.summary,
|
||||||
|
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
|
||||||
|
"child_count": c.child_count,
|
||||||
|
"smart": c.smart,
|
||||||
|
"editable": c.editable,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/collections")
|
||||||
|
def collections(
|
||||||
|
library: str,
|
||||||
|
q: str | None = None,
|
||||||
|
user: User = Depends(current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""The library's mirrored collections as cards (most-populated first). Optional name filter `q`."""
|
||||||
|
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
||||||
|
if lib is None:
|
||||||
|
return {"collections": []}
|
||||||
|
query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id)
|
||||||
|
term = (q or "").strip()
|
||||||
|
if term:
|
||||||
|
query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%"))
|
||||||
|
rows = query.order_by(
|
||||||
|
PlexCollection.child_count.desc().nullslast(), func.lower(PlexCollection.title)
|
||||||
|
).all()
|
||||||
|
return {"collections": [_collection_card(c) for c in rows]}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/show/{rating_key}")
|
@router.get("/show/{rating_key}")
|
||||||
def show_detail(
|
def show_detail(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
|
|
@ -482,6 +520,9 @@ def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
|
||||||
se = db.query(PlexSeason).filter_by(rating_key=rating_key).first()
|
se = db.query(PlexSeason).filter_by(rating_key=rating_key).first()
|
||||||
if se is not None:
|
if se is not None:
|
||||||
return se.thumb_key
|
return se.thumb_key
|
||||||
|
col = db.query(PlexCollection).filter_by(rating_key=rating_key).first()
|
||||||
|
if col is not None:
|
||||||
|
return col.thumb_key
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -643,6 +684,63 @@ def person_image(u: str, _: User = Depends(current_user)) -> Response:
|
||||||
return _img_response(r.content, ct)
|
return _img_response(r.content, ct)
|
||||||
|
|
||||||
|
|
||||||
|
# Known external list providers that seed Plex collections (via plextraktsync et al). A collection's
|
||||||
|
# "source" buckets the info-page strips so the user can show/hide each type independently (e.g. hide
|
||||||
|
# the noisy 'IMDb Popular'/'TMDb Trending' auto-lists but keep genuine franchise collections). Derived
|
||||||
|
# from the already-synced title/smart — no schema change, no re-sync. Unknown providers stay "collection".
|
||||||
|
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
|
||||||
|
|
||||||
|
|
||||||
|
def _collection_source(col: PlexCollection) -> str:
|
||||||
|
first = (col.title or "").strip().split(" ", 1)[0].lower()
|
||||||
|
if first in _STRIP_PROVIDERS:
|
||||||
|
return first
|
||||||
|
if col.smart:
|
||||||
|
return "smart"
|
||||||
|
return "collection"
|
||||||
|
|
||||||
|
|
||||||
|
def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]:
|
||||||
|
"""The collections this movie belongs to, each with its member titles (playable cards) — the
|
||||||
|
'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
|
||||||
|
keys = it.collection_keys or []
|
||||||
|
if not keys or it.kind != "movie":
|
||||||
|
return []
|
||||||
|
cols = {c.rating_key: c for c in db.query(PlexCollection).filter(PlexCollection.rating_key.in_(keys))}
|
||||||
|
# Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
|
||||||
|
ordered = sorted((c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title))
|
||||||
|
out = []
|
||||||
|
for col in ordered:
|
||||||
|
rk = col.rating_key
|
||||||
|
members = (
|
||||||
|
db.query(PlexItem)
|
||||||
|
.filter(
|
||||||
|
PlexItem.library_id == it.library_id,
|
||||||
|
PlexItem.kind == "movie",
|
||||||
|
PlexItem.collection_keys.contains([rk]),
|
||||||
|
)
|
||||||
|
.order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
|
||||||
|
.limit(60)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
ids = [m.id for m in members]
|
||||||
|
states = {
|
||||||
|
s.item_id: s
|
||||||
|
for s in db.query(PlexState).filter(
|
||||||
|
PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"id": rk,
|
||||||
|
"title": col.title,
|
||||||
|
"source": _collection_source(col),
|
||||||
|
"items": [_movie_card(m, states.get(m.id)) for m in members],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
||||||
if it.kind != "episode" or it.show_id is None:
|
if it.kind != "episode" or it.show_id is None:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
@ -742,6 +840,7 @@ def item_detail(
|
||||||
"episode_number": it.episode_number,
|
"episode_number": it.episode_number,
|
||||||
"prev_id": prev_id,
|
"prev_id": prev_id,
|
||||||
"next_id": next_id,
|
"next_id": next_id,
|
||||||
|
"collections": _collection_strips(db, it, user.id),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,12 +52,17 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
||||||
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||||
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
|
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
|
||||||
const scrollRef = useRef(0);
|
const scrollRef = useRef(0);
|
||||||
|
// Same idea for the info page: "Browse collection" (onFilter) leaves the info view for a filtered
|
||||||
|
// grid, and browser Back returns to this same info page — restore where the user had scrolled it
|
||||||
|
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
|
||||||
|
// different title's info starts at the top.
|
||||||
|
const infoScrollRef = useRef(0);
|
||||||
const scroller = () => document.querySelector("main");
|
const scroller = () => document.querySelector("main");
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (sub.view.kind === "grid" && scrollRef.current) {
|
|
||||||
const el = scroller();
|
const el = scroller();
|
||||||
if (el) el.scrollTop = scrollRef.current;
|
if (!el) return;
|
||||||
}
|
if (sub.view.kind === "grid" && scrollRef.current) el.scrollTop = scrollRef.current;
|
||||||
|
else if (sub.view.kind === "info" && infoScrollRef.current) el.scrollTop = infoScrollRef.current;
|
||||||
}, [sub.view.kind]);
|
}, [sub.view.kind]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
|
|
@ -121,6 +126,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
||||||
}
|
}
|
||||||
function onInfo(card: PlexCard) {
|
function onInfo(card: PlexCard) {
|
||||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||||
|
infoScrollRef.current = 0; // fresh info page starts at the top
|
||||||
sub.open({ kind: "info", id: card.id });
|
sub.open({ kind: "info", id: card.id });
|
||||||
}
|
}
|
||||||
async function toggleWatched(card: PlexCard) {
|
async function toggleWatched(card: PlexCard) {
|
||||||
|
|
@ -142,6 +148,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
||||||
id={infoId}
|
id={infoId}
|
||||||
onBack={sub.back}
|
onBack={sub.back}
|
||||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||||
|
onPlayItem={(id) => sub.open({ kind: "player", id })}
|
||||||
onFilter={(patch) => {
|
onFilter={(patch) => {
|
||||||
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
|
// Clicking a metadata chip sets that filter and shows the filtered grid. Array filters
|
||||||
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
|
// (genres/people/studios) UNION with what's already set (so you can stack people); scalars
|
||||||
|
|
@ -157,6 +164,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setFilters(merged as unknown as PlexFilters);
|
setFilters(merged as unknown as PlexFilters);
|
||||||
|
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to this info page
|
||||||
sub.open({ kind: "grid" });
|
sub.open({ kind: "grid" });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -360,17 +368,19 @@ function PlexInfoView({
|
||||||
id,
|
id,
|
||||||
onBack,
|
onBack,
|
||||||
onPlay,
|
onPlay,
|
||||||
|
onPlayItem,
|
||||||
onFilter,
|
onFilter,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onPlay: () => void;
|
onPlay: () => void;
|
||||||
|
onPlayItem: (id: string) => void;
|
||||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
|
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[1100px] mx-auto">
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
|
|
@ -384,7 +394,13 @@ function PlexInfoView({
|
||||||
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>
|
<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>
|
||||||
) : (
|
) : (
|
||||||
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
<Suspense fallback={<p className="p-8 text-muted text-sm">{t("plex.loading")}</p>}>
|
||||||
<PlexInfo detail={q.data} variant="page" onPlay={onPlay} onFilter={onFilter} />
|
<PlexInfo
|
||||||
|
detail={q.data}
|
||||||
|
variant="page"
|
||||||
|
onPlay={onPlay}
|
||||||
|
onPlayItem={onPlayItem}
|
||||||
|
onFilter={onFilter}
|
||||||
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, type ReactNode } from "react";
|
import { useLayoutEffect, useRef, useState, type ReactNode } 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 {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
|
@ -27,6 +28,8 @@ type Props = {
|
||||||
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
|
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
|
||||||
// the corresponding Plex filter (then the opener navigates to the filtered grid).
|
// the corresponding Plex filter (then the opener navigates to the filtered grid).
|
||||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||||
|
// Play a sibling title from a collection strip (opens its player).
|
||||||
|
onPlayItem?: (id: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
|
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
|
||||||
|
|
@ -56,17 +59,70 @@ function fmtDur(s?: number | null): string {
|
||||||
return h ? `${h}h ${m}m` : `${m}m`;
|
return h ? `${h}h ${m}m` : `${m}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange, onFilter }: Props) {
|
export default function PlexInfo({
|
||||||
|
detail,
|
||||||
|
variant,
|
||||||
|
onPlay,
|
||||||
|
onClose,
|
||||||
|
onStateChange,
|
||||||
|
onFilter,
|
||||||
|
onPlayItem,
|
||||||
|
}: Props) {
|
||||||
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). Stored per-user; the backend merges any pref key.
|
||||||
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 };
|
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean; plexHiddenStripSources?: string[] };
|
||||||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
||||||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
||||||
|
// 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.
|
||||||
|
const [hiddenSources, setHiddenSources] = useState<string[]>(
|
||||||
|
Array.isArray(prefs.plexHiddenStripSources) ? prefs.plexHiddenStripSources : [],
|
||||||
|
);
|
||||||
|
const toggleSource = (src: string) => {
|
||||||
|
setHiddenSources((cur) => {
|
||||||
|
const next = cur.includes(src) ? cur.filter((s) => s !== src) : [...cur, src];
|
||||||
|
savePref({ plexHiddenStripSources: next });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
const [customizing, setCustomizing] = useState(false);
|
const [customizing, setCustomizing] = useState(false);
|
||||||
const savePref = (patch: Record<string, boolean>) => {
|
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
|
||||||
|
// container (<main>) so it fills the content area and stays put while the info page scrolls over it
|
||||||
|
// (like the body's ambient pools). Scoped to <main> → never covers the nav/filter sidebars. A soft
|
||||||
|
// 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(() => {});
|
api.savePrefs(patch).catch(() => {});
|
||||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
||||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||||
|
|
@ -86,17 +142,22 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
const overlay = variant === "overlay";
|
const overlay = variant === "overlay";
|
||||||
const mutedCls = overlay ? "text-white/70" : "text-muted";
|
const mutedCls = overlay ? "text-white/70" : "text-muted";
|
||||||
|
|
||||||
|
// Distinct strip-source buckets present on THIS item, in strip order — one toggle each.
|
||||||
|
const presentSources = (detail.collections ?? []).reduce<string[]>((acc, c) => {
|
||||||
|
if (!acc.includes(c.source)) acc.push(c.source);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
const sourceLabel = (src: string) =>
|
||||||
|
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}>
|
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}>
|
||||||
{/* Faint art backdrop (page only, toggleable). */}
|
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
|
||||||
{!overlay && artBg && detail.art && (
|
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
|
||||||
<div className="absolute inset-0 -z-10 overflow-hidden rounded-2xl">
|
<div
|
||||||
<img src={detail.art} alt="" className="w-full h-full object-cover opacity-20" />
|
className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-bg via-bg/85 to-bg/60" />
|
style={overlay ? undefined : { background: "color-mix(in srgb, var(--surface) 55%, transparent)" }}
|
||||||
</div>
|
>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={overlay ? "p-5" : "p-4 sm:p-6"}>
|
|
||||||
{/* Header: close (overlay) / customize */}
|
{/* Header: close (overlay) / customize */}
|
||||||
<div className="mb-4 flex items-start gap-3">
|
<div className="mb-4 flex items-start gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
@ -111,6 +172,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
</div>
|
</div>
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
|
ref={custBtnRef}
|
||||||
onClick={() => setCustomizing((v) => !v)}
|
onClick={() => setCustomizing((v) => !v)}
|
||||||
title={t("plex.info.customize")}
|
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"}`}
|
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
|
||||||
|
|
@ -118,7 +180,11 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
<SlidersHorizontal className="w-4 h-4" />
|
<SlidersHorizontal className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{customizing && (
|
{customizing && (
|
||||||
<div className="absolute right-0 top-full z-10 mt-1 w-52 rounded-xl border border-border bg-card p-1.5 text-sm shadow-xl">
|
<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
|
<PrefToggle
|
||||||
label={t("plex.info.prefArtBg")}
|
label={t("plex.info.prefArtBg")}
|
||||||
on={artBg}
|
on={artBg}
|
||||||
|
|
@ -135,6 +201,14 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
savePref({ plexInfoCast: !showCast });
|
savePref({ plexInfoCast: !showCast });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{presentSources.map((src) => (
|
||||||
|
<PrefToggle
|
||||||
|
key={src}
|
||||||
|
label={sourceLabel(src)}
|
||||||
|
on={!hiddenSources.includes(src)}
|
||||||
|
onClick={() => toggleSource(src)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -263,7 +337,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
{detail.status === "watched" ? (
|
{detail.status === "watched" ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setState("new")}
|
onClick={() => setState("new")}
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
{t("plex.info.markUnwatched")}
|
{t("plex.info.markUnwatched")}
|
||||||
|
|
@ -271,7 +345,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setState("watched")}
|
onClick={() => setState("watched")}
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4" />
|
<Check className="w-4 h-4" />
|
||||||
{t("plex.info.markWatched")}
|
{t("plex.info.markWatched")}
|
||||||
|
|
@ -280,7 +354,7 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
{inProgress && (
|
{inProgress && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setState("new")}
|
onClick={() => setState("new")}
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
{t("plex.info.clearResume")}
|
{t("plex.info.clearResume")}
|
||||||
|
|
@ -290,10 +364,15 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* End hero panel. */}
|
||||||
|
|
||||||
{/* Cast row (toggleable), circular photos. */}
|
{/* Cast row (toggleable), circular photos. */}
|
||||||
{cast.length > 0 && (
|
{cast.length > 0 && (
|
||||||
<div className="mt-5">
|
<div
|
||||||
|
className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}
|
||||||
|
style={overlay ? undefined : { background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
|
>
|
||||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||||
{t("plex.info.cast")}
|
{t("plex.info.cast")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -346,6 +425,68 @@ export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChan
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Collection strips — the other titles in this movie's collection(s), directly playable.
|
||||||
|
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
|
||||||
|
{detail.collections
|
||||||
|
?.filter((col) => !hiddenSources.includes(col.source))
|
||||||
|
.map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.id}
|
||||||
|
className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}
|
||||||
|
style={overlay ? undefined : { background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2>
|
||||||
|
{onFilter && (
|
||||||
|
<button
|
||||||
|
onClick={() => onFilter({ collection: col.id, collectionTitle: col.title })}
|
||||||
|
className="shrink-0 text-xs text-muted hover:text-accent"
|
||||||
|
>
|
||||||
|
{t("plex.info.browseCollection")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
{col.items.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => onPlayItem?.(m.id)}
|
||||||
|
disabled={!onPlayItem}
|
||||||
|
className="group/col w-28 shrink-0 text-left"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[2/3] overflow-hidden rounded-lg border border-border bg-surface">
|
||||||
|
<img
|
||||||
|
src={m.thumb}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover transition group-hover/col:scale-105"
|
||||||
|
/>
|
||||||
|
{m.status === "watched" && (
|
||||||
|
<span className="absolute right-1 top-1 rounded bg-black/70 px-1 text-[9px] text-white">✓</span>
|
||||||
|
)}
|
||||||
|
{onPlayItem && (
|
||||||
|
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/col:opacity-100">
|
||||||
|
<Play className="h-8 w-8 text-white" fill="currentColor" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(m.position_seconds ?? 0) > 0 && m.status !== "watched" && m.duration_seconds ? (
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-0.5 bg-black/40">
|
||||||
|
<div
|
||||||
|
className="h-full bg-accent"
|
||||||
|
style={{ width: `${Math.min(100, ((m.position_seconds ?? 0) / m.duration_seconds) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className={`mt-1 truncate text-xs font-medium ${overlay ? "text-white/90" : ""}`}>
|
||||||
|
{m.title}
|
||||||
|
</div>
|
||||||
|
{m.year ? <div className="text-[11px] text-muted">{m.year}</div> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronRight, Film, SlidersHorizontal, Tv2, X } from "lucide-react";
|
import { ChevronRight, Film, Layers, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||||
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
|
|
||||||
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
|
// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the
|
||||||
// full metadata filter set (genre / rating / year / duration / added / content rating + the
|
// full metadata filter set (genre / rating / year / duration / added / content rating + the
|
||||||
|
|
@ -60,6 +61,16 @@ export default function PlexSidebar({
|
||||||
});
|
});
|
||||||
const facets = facetsQ.data;
|
const facets = facetsQ.data;
|
||||||
|
|
||||||
|
// Collections picker (searchable; the list is only fetched when none is active).
|
||||||
|
const [collSearch, setCollSearch] = useState("");
|
||||||
|
const collSearchDeb = useDebounced(collSearch.trim(), 300);
|
||||||
|
const collectionsQ = useQuery({
|
||||||
|
queryKey: ["plex-collections", library, collSearchDeb],
|
||||||
|
queryFn: () => api.plexCollections(library, collSearchDeb || undefined),
|
||||||
|
enabled: !!library && isMovieLib && !filters.collection,
|
||||||
|
});
|
||||||
|
const collections = collectionsQ.data?.collections ?? [];
|
||||||
|
|
||||||
// Default to the first library once loaded (or if the stored one vanished).
|
// Default to the first library once loaded (or if the stored one vanished).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
||||||
|
|
@ -205,6 +216,43 @@ export default function PlexSidebar({
|
||||||
</Section>
|
</Section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Collection — pick one from the searchable list (or the active one shows as a chip). */}
|
||||||
|
<Section label={t("plex.filter.collection")}>
|
||||||
|
{filters.collection ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<RemovableChip
|
||||||
|
label={filters.collectionTitle || filters.collection}
|
||||||
|
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={collSearch}
|
||||||
|
onChange={(e) => setCollSearch(e.target.value)}
|
||||||
|
placeholder={t("plex.filter.collectionSearch")}
|
||||||
|
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
||||||
|
{collections.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
|
||||||
|
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
|
||||||
|
>
|
||||||
|
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
|
||||||
|
<span className="flex-1 truncate">{c.title}</span>
|
||||||
|
<span className="text-xs text-muted">{c.child_count}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{collections.length === 0 && (
|
||||||
|
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
{/* IMDb rating min */}
|
{/* IMDb rating min */}
|
||||||
<Section label={t("plex.filter.rating")}>
|
<Section label={t("plex.filter.rating")}>
|
||||||
<ChipRow>
|
<ChipRow>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,10 @@
|
||||||
"addedOpt": { "24h": "24 Std.", "1w": "1 Woche", "1m": "1 Monat", "6m": "6 Monate", "1y": "1 Jahr" },
|
"addedOpt": { "24h": "24 Std.", "1w": "1 Woche", "1m": "1 Monat", "6m": "6 Monate", "1y": "1 Jahr" },
|
||||||
"contentRating": "Altersfreigabe",
|
"contentRating": "Altersfreigabe",
|
||||||
"match": { "any": "Beliebig", "all": "Alle" },
|
"match": { "any": "Beliebig", "all": "Alle" },
|
||||||
"dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" }
|
"dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" },
|
||||||
|
"collection": "Sammlung",
|
||||||
|
"collectionSearch": "Sammlungen suchen…",
|
||||||
|
"collectionEmpty": "Keine Sammlungen"
|
||||||
},
|
},
|
||||||
"count": "{{count}} Titel",
|
"count": "{{count}} Titel",
|
||||||
"searchCount": "{{count}} Treffer",
|
"searchCount": "{{count}} Treffer",
|
||||||
|
|
@ -95,6 +98,14 @@
|
||||||
"customize": "Ansicht anpassen",
|
"customize": "Ansicht anpassen",
|
||||||
"prefArtBg": "Dezenter Hintergrund",
|
"prefArtBg": "Dezenter Hintergrund",
|
||||||
"prefCast": "Besetzung",
|
"prefCast": "Besetzung",
|
||||||
|
"stripSource": {
|
||||||
|
"collection": "Sammlungen",
|
||||||
|
"imdb": "IMDb",
|
||||||
|
"tmdb": "TMDb",
|
||||||
|
"tvdb": "TVDb",
|
||||||
|
"trakt": "Trakt",
|
||||||
|
"smart": "Smart-Listen"
|
||||||
|
},
|
||||||
"openImdb": "Bei IMDb öffnen",
|
"openImdb": "Bei IMDb öffnen",
|
||||||
"director": "Regie",
|
"director": "Regie",
|
||||||
"cast": "Besetzung & Crew",
|
"cast": "Besetzung & Crew",
|
||||||
|
|
@ -103,7 +114,8 @@
|
||||||
"clearResume": "Fortsetzungsposition löschen",
|
"clearResume": "Fortsetzungsposition löschen",
|
||||||
"filterYear": "Titel aus {{year}}",
|
"filterYear": "Titel aus {{year}}",
|
||||||
"filterRating": "Titel mit {{n}}+",
|
"filterRating": "Titel mit {{n}}+",
|
||||||
"filterActor": "Titel mit {{name}}"
|
"filterActor": "Titel mit {{name}}",
|
||||||
|
"browseCollection": "Sammlung durchsuchen →"
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Spielt direkt im Browser",
|
"direct": "Spielt direkt im Browser",
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,10 @@
|
||||||
"addedOpt": { "24h": "24h", "1w": "1 week", "1m": "1 month", "6m": "6 months", "1y": "1 year" },
|
"addedOpt": { "24h": "24h", "1w": "1 week", "1m": "1 month", "6m": "6 months", "1y": "1 year" },
|
||||||
"contentRating": "Age rating",
|
"contentRating": "Age rating",
|
||||||
"match": { "any": "Any", "all": "All" },
|
"match": { "any": "Any", "all": "All" },
|
||||||
"dir": { "desc": "↓ Descending", "asc": "↑ Ascending" }
|
"dir": { "desc": "↓ Descending", "asc": "↑ Ascending" },
|
||||||
|
"collection": "Collection",
|
||||||
|
"collectionSearch": "Search collections…",
|
||||||
|
"collectionEmpty": "No collections"
|
||||||
},
|
},
|
||||||
"count": "{{count}} titles",
|
"count": "{{count}} titles",
|
||||||
"searchCount": "{{count}} matches",
|
"searchCount": "{{count}} matches",
|
||||||
|
|
@ -95,6 +98,14 @@
|
||||||
"customize": "Customize view",
|
"customize": "Customize view",
|
||||||
"prefArtBg": "Faint backdrop",
|
"prefArtBg": "Faint backdrop",
|
||||||
"prefCast": "Cast & crew",
|
"prefCast": "Cast & crew",
|
||||||
|
"stripSource": {
|
||||||
|
"collection": "Collections",
|
||||||
|
"imdb": "IMDb",
|
||||||
|
"tmdb": "TMDb",
|
||||||
|
"tvdb": "TVDb",
|
||||||
|
"trakt": "Trakt",
|
||||||
|
"smart": "Smart lists"
|
||||||
|
},
|
||||||
"openImdb": "Open on IMDb",
|
"openImdb": "Open on IMDb",
|
||||||
"director": "Director",
|
"director": "Director",
|
||||||
"cast": "Cast & crew",
|
"cast": "Cast & crew",
|
||||||
|
|
@ -103,7 +114,8 @@
|
||||||
"clearResume": "Clear resume position",
|
"clearResume": "Clear resume position",
|
||||||
"filterYear": "Show titles from {{year}}",
|
"filterYear": "Show titles from {{year}}",
|
||||||
"filterRating": "Show titles rated {{n}}+",
|
"filterRating": "Show titles rated {{n}}+",
|
||||||
"filterActor": "Show titles with {{name}}"
|
"filterActor": "Show titles with {{name}}",
|
||||||
|
"browseCollection": "Browse collection →"
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Plays directly in the browser",
|
"direct": "Plays directly in the browser",
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,10 @@
|
||||||
"addedOpt": { "24h": "24 óra", "1w": "1 hét", "1m": "1 hónap", "6m": "6 hónap", "1y": "1 év" },
|
"addedOpt": { "24h": "24 óra", "1w": "1 hét", "1m": "1 hónap", "6m": "6 hónap", "1y": "1 év" },
|
||||||
"contentRating": "Korhatár",
|
"contentRating": "Korhatár",
|
||||||
"match": { "any": "Bármelyik", "all": "Mind" },
|
"match": { "any": "Bármelyik", "all": "Mind" },
|
||||||
"dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" }
|
"dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" },
|
||||||
|
"collection": "Kollekció",
|
||||||
|
"collectionSearch": "Kollekciók keresése…",
|
||||||
|
"collectionEmpty": "Nincs kollekció"
|
||||||
},
|
},
|
||||||
"count": "{{count}} cím",
|
"count": "{{count}} cím",
|
||||||
"searchCount": "{{count}} találat",
|
"searchCount": "{{count}} találat",
|
||||||
|
|
@ -95,6 +98,14 @@
|
||||||
"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",
|
||||||
|
"stripSource": {
|
||||||
|
"collection": "Kollekciók",
|
||||||
|
"imdb": "IMDb",
|
||||||
|
"tmdb": "TMDb",
|
||||||
|
"tvdb": "TVDb",
|
||||||
|
"trakt": "Trakt",
|
||||||
|
"smart": "Smart listák"
|
||||||
|
},
|
||||||
"openImdb": "Megnyitás az IMDb-n",
|
"openImdb": "Megnyitás az IMDb-n",
|
||||||
"director": "Rendező",
|
"director": "Rendező",
|
||||||
"cast": "Szereplők & stáb",
|
"cast": "Szereplők & stáb",
|
||||||
|
|
@ -103,7 +114,8 @@
|
||||||
"clearResume": "Folytatási pozíció törlése",
|
"clearResume": "Folytatási pozíció törlése",
|
||||||
"filterYear": "{{year}} évi címek",
|
"filterYear": "{{year}} évi címek",
|
||||||
"filterRating": "{{n}}+ pontos címek",
|
"filterRating": "{{n}}+ pontos címek",
|
||||||
"filterActor": "Címek {{name}} szereplésével"
|
"filterActor": "Címek {{name}} szereplésével",
|
||||||
|
"browseCollection": "Kollekció böngészése →"
|
||||||
},
|
},
|
||||||
"playable": {
|
"playable": {
|
||||||
"direct": "Közvetlenül játszható a böngészőben",
|
"direct": "Közvetlenül játszható a böngészőben",
|
||||||
|
|
|
||||||
|
|
@ -714,6 +714,18 @@ export interface PlexItemDetail {
|
||||||
episode_number?: number | null;
|
episode_number?: number | null;
|
||||||
prev_id?: string | null;
|
prev_id?: string | null;
|
||||||
next_id?: string | null;
|
next_id?: string | null;
|
||||||
|
// `source` buckets the strip so each type can be shown/hidden independently on the info page:
|
||||||
|
// "collection" (genuine franchise), "imdb"/"tmdb"/"tvdb"/"trakt" (external auto-lists), "smart".
|
||||||
|
collections?: { id: string; title: string; source: string; items: PlexCard[] }[];
|
||||||
|
}
|
||||||
|
export interface PlexCollection {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
summary?: string | null;
|
||||||
|
thumb?: string | null;
|
||||||
|
child_count?: number | null;
|
||||||
|
smart: boolean;
|
||||||
|
editable: boolean;
|
||||||
}
|
}
|
||||||
export interface PlexCastMember {
|
export interface PlexCastMember {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -734,6 +746,8 @@ export interface PlexFilters {
|
||||||
directors: string[]; // AND — titles featuring ALL selected
|
directors: string[]; // AND — titles featuring ALL selected
|
||||||
actors: string[]; // AND — titles featuring ALL selected
|
actors: string[]; // AND — titles featuring ALL selected
|
||||||
studios: string[]; // OR — from any selected studio
|
studios: string[]; // OR — from any selected studio
|
||||||
|
collection?: string | null; // a single Plex collection (rating_key)
|
||||||
|
collectionTitle?: string | null; // its title, for the active-filter chip
|
||||||
sortDir?: "asc" | "desc"; // sort direction (default desc)
|
sortDir?: "asc" | "desc"; // sort direction (default desc)
|
||||||
}
|
}
|
||||||
export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
||||||
|
|
@ -753,7 +767,8 @@ export function plexFilterCount(f: PlexFilters): number {
|
||||||
(f.addedWithin ? 1 : 0) +
|
(f.addedWithin ? 1 : 0) +
|
||||||
f.directors.length +
|
f.directors.length +
|
||||||
f.actors.length +
|
f.actors.length +
|
||||||
f.studios.length
|
f.studios.length +
|
||||||
|
(f.collection ? 1 : 0)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export interface PlexPerson {
|
export interface PlexPerson {
|
||||||
|
|
@ -1122,6 +1137,7 @@ export const api = {
|
||||||
if (f.directors?.length) u.set("directors", f.directors.join(","));
|
if (f.directors?.length) u.set("directors", f.directors.join(","));
|
||||||
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
||||||
if (f.studios?.length) u.set("studios", f.studios.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");
|
if (f.sortDir === "asc") u.set("sort_dir", "asc");
|
||||||
}
|
}
|
||||||
return req(`/api/plex/browse?${u.toString()}`);
|
return req(`/api/plex/browse?${u.toString()}`);
|
||||||
|
|
@ -1130,6 +1146,8 @@ export const api = {
|
||||||
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
||||||
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
|
plexPeople: (library: string, q: string): Promise<{ people: PlexPerson[] }> =>
|
||||||
req(`/api/plex/people?library=${encodeURIComponent(library)}&q=${encodeURIComponent(q)}`),
|
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)}` : ""}`),
|
||||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,18 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.28.0",
|
||||||
|
date: "2026-07-06",
|
||||||
|
summary: "Plex collections — browse them, filter by them, and see a title's collection right on its info page.",
|
||||||
|
features: [
|
||||||
|
"Plex collections: every Plex collection (franchises like Avatar, curated sets, and community lists like IMDb Top 250) is now mirrored locally by the background sync, so browsing is instant — no more waiting on Plex's slow collection queries.",
|
||||||
|
"Plex library: a searchable Collection filter in the sidebar — pick a collection to see just its titles, combinable with every other filter.",
|
||||||
|
"Plex info page: a title's collection(s) now appear as strips of the sibling films, playable straight from there (most-specific collection first).",
|
||||||
|
"Plex info page: show or hide each collection-strip type independently — keep genuine franchises (Avatar, James Bond) while hiding the big auto-lists (IMDb Top 250, TMDb Trending), remembered per account.",
|
||||||
|
"Plex info page: a refreshed, wider layout — content floats as frosted-glass panels over a faint, fixed backdrop of the title's own art (toggleable), matching the app's glassy look.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.27.0",
|
version: "0.27.0",
|
||||||
date: "2026-07-06",
|
date: "2026-07-06",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue