merge: Plex collections Phase 1 + info-page polish — v0.28.0

This commit is contained in:
npeter83 2026-07-06 06:50:21 +02:00
commit 4b360f8f36
14 changed files with 542 additions and 37 deletions

View file

@ -1 +1 @@
0.27.0 0.28.0

View 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")

View file

@ -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).

View file

@ -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

View file

@ -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()

View file

@ -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),
} }

View file

@ -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>

View file

@ -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>
); );

View file

@ -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>

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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> =>

View file

@ -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",