Merge: promote dev to prod
This commit is contained in:
commit
a3dde73670
17 changed files with 554 additions and 43 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",
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,8 @@
|
||||||
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
|
"playlist_sync": "Spiegelt die YouTube-Wiedergabelisten jedes Nutzers in die App. Fällt er aus, erscheinen Änderungen an deinen YouTube-Listen lokal nicht.",
|
||||||
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.",
|
"maintenance": "Findet Videos, die nicht mehr abspielbar sind (gelöscht, auf privat gesetzt, abgebrochene Premieren), blendet sie aus dem Feed aus und entfernt sie nach einer Schonfrist; prüft die am längsten nicht geprüften Videos erneut. Fällt er aus, bleiben tote Videos im Katalog.",
|
||||||
"explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen.",
|
"explore_cleanup": "Entfernt Kanäle, die du erkundet, aber nie abonniert hast (und ihre Videos), nach einer Karenzzeit, damit Neugier-Stöbern den Katalog nicht überfüllt. Wenn es stoppt, bleiben verlassene erkundete Kanäle bestehen.",
|
||||||
"download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben."
|
"download_gc": "Löscht heruntergeladene Dateien nach Ablauf der Aufbewahrungsfrist, gibt nicht mehr belegten Speicher frei und warnt vor dem Ablauf einer geteilten Datei. Fällt er aus, häufen sich alte Downloads und Speicherplatz wird nicht freigegeben.",
|
||||||
|
"plex_sync": "Spiegelt die aktivierten Plex-Bibliotheken in den App-Katalog — die Metadaten hinter Plex-Stöbern, Suche, Filtern und Info-Seiten (Genres, Besetzung, Wertungen, Artwork). Fällt er aus, erscheinen neu hinzugefügte oder geänderte Plex-Titel erst beim nächsten Sync."
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
"rss_poll": "RSS-Abfrage (neue Uploads)",
|
||||||
|
|
@ -81,7 +82,8 @@
|
||||||
"maintenance": "Wartung + Validierung",
|
"maintenance": "Wartung + Validierung",
|
||||||
"demo_reset": "Demo-Konto-Reset",
|
"demo_reset": "Demo-Konto-Reset",
|
||||||
"explore_cleanup": "Bereinigung erkundeter Kanäle",
|
"explore_cleanup": "Bereinigung erkundeter Kanäle",
|
||||||
"download_gc": "Download-Bereinigung"
|
"download_gc": "Download-Bereinigung",
|
||||||
|
"plex_sync": "Plex-Bibliothek-Sync"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Zu synchronisierende Kanäle",
|
"recentPending": "Zu synchronisierende Kanäle",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,8 @@
|
||||||
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
|
"playlist_sync": "Mirrors each user's YouTube playlists into the app. If it stops, changes to your YouTube playlists don't show up locally.",
|
||||||
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
|
"maintenance": "Finds videos that can no longer be played (deleted, made private, abandoned premieres), hides them from the feed and removes them after a grace period; re-checks the least-recently-checked videos. If it stops, dead videos linger in the catalogue.",
|
||||||
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
|
"explore_cleanup": "Removes channels you explored but never subscribed to (and their videos) after a grace period, so curiosity browsing doesn't pile up in the catalogue. If it stops, abandoned explored channels linger.",
|
||||||
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed."
|
"download_gc": "Deletes downloaded files past their retention window, reclaims space no one is holding anymore, and warns owners before a shared file expires. If it stops, old downloads pile up and disk space isn't freed.",
|
||||||
|
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync."
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS poll (new uploads)",
|
"rss_poll": "RSS poll (new uploads)",
|
||||||
|
|
@ -81,7 +82,8 @@
|
||||||
"maintenance": "Maintenance + validation",
|
"maintenance": "Maintenance + validation",
|
||||||
"demo_reset": "Demo account reset",
|
"demo_reset": "Demo account reset",
|
||||||
"explore_cleanup": "Explored-channel cleanup",
|
"explore_cleanup": "Explored-channel cleanup",
|
||||||
"download_gc": "Download cleanup"
|
"download_gc": "Download cleanup",
|
||||||
|
"plex_sync": "Plex library sync"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Channels to sync",
|
"recentPending": "Channels to sync",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,8 @@
|
||||||
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
|
"playlist_sync": "Tükrözi a felhasználók YouTube lejátszási listáit az appba. Ha leáll, a YouTube-listák változásai nem jelennek meg lokálisan.",
|
||||||
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
|
"maintenance": "Megkeresi a már sehol le nem játszható videókat (törölt, priváttá tett, elhagyott premier), elrejti őket a hírfolyamból, és türelmi idő után eltávolítja; újraellenőrzi a legrégebben ellenőrzött videókat. Ha leáll, a halott videók a katalógusban maradnak.",
|
||||||
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
|
"explore_cleanup": "A türelmi idő után eltávolítja a felfedezett, de nem követett csatornákat (és videóikat), hogy a kíváncsiság-böngészés ne halmozódjon a katalógusban. Ha leáll, az elhagyott felfedezett csatornák megmaradnak.",
|
||||||
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel."
|
"download_gc": "Törli a megőrzési időn túli letöltéseket, felszabadítja a már senki által nem használt helyet, és a lejárat előtt figyelmezteti a tulajdonost. Ha leáll, a régi letöltések felhalmozódnak, és a tárhely nem szabadul fel.",
|
||||||
|
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg."
|
||||||
},
|
},
|
||||||
"jobs": {
|
"jobs": {
|
||||||
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
"rss_poll": "RSS-lekérdezés (új feltöltések)",
|
||||||
|
|
@ -81,7 +82,8 @@
|
||||||
"maintenance": "Karbantartás + ellenőrzés",
|
"maintenance": "Karbantartás + ellenőrzés",
|
||||||
"demo_reset": "Demo fiók visszaállítása",
|
"demo_reset": "Demo fiók visszaállítása",
|
||||||
"explore_cleanup": "Felfedezett csatornák takarítása",
|
"explore_cleanup": "Felfedezett csatornák takarítása",
|
||||||
"download_gc": "Letöltések takarítása"
|
"download_gc": "Letöltések takarítása",
|
||||||
|
"plex_sync": "Plex-könyvtár szinkron"
|
||||||
},
|
},
|
||||||
"queue": {
|
"queue": {
|
||||||
"recentPending": "Szinkronra váró csatorna",
|
"recentPending": "Szinkronra váró csatorna",
|
||||||
|
|
|
||||||
|
|
@ -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