merge: Plex info page + rich metadata + expanded filters + clickable-to-filter — v0.26.0
This commit is contained in:
commit
6c09420068
16 changed files with 1456 additions and 77 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.24.0
|
||||
0.26.0
|
||||
62
backend/alembic/versions/0045_plex_filter_meta.py
Normal file
62
backend/alembic/versions/0045_plex_filter_meta.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Plex filterable/orderable metadata
|
||||
|
||||
Revision ID: 0045_plex_filter_meta
|
||||
Revises: 0044_plex_catalog
|
||||
Create Date: 2026-07-05
|
||||
|
||||
Adds the metadata the expanded Plex filter sidebar filters + orders on, mirrored cheaply from the
|
||||
Plex section listing (no per-item API calls): rating (audienceRating ~ IMDb), content rating, studio,
|
||||
release date, and GIN-indexed genre / director / cast arrays. These also double as the content
|
||||
signals a future watch-habit recommender would use.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0045_plex_filter_meta"
|
||||
down_revision: Union[str, None] = "0044_plex_catalog"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("plex_items", sa.Column("rating", sa.Float(), nullable=True))
|
||||
op.add_column("plex_items", sa.Column("content_rating", sa.String(length=16), nullable=True))
|
||||
op.add_column("plex_items", sa.Column("studio", sa.String(length=255), nullable=True))
|
||||
op.add_column("plex_items", sa.Column("originally_available_at", sa.Date(), nullable=True))
|
||||
op.add_column("plex_items", sa.Column("genres", postgresql.JSONB(), nullable=True))
|
||||
op.add_column("plex_items", sa.Column("directors", postgresql.JSONB(), nullable=True))
|
||||
op.add_column("plex_items", sa.Column("cast_names", postgresql.JSONB(), nullable=True))
|
||||
|
||||
op.create_index("ix_plex_items_rating", "plex_items", ["rating"])
|
||||
op.create_index("ix_plex_items_content_rating", "plex_items", ["content_rating"])
|
||||
op.create_index("ix_plex_items_studio", "plex_items", ["studio"])
|
||||
op.create_index("ix_plex_items_originally_available_at", "plex_items", ["originally_available_at"])
|
||||
op.create_index("ix_plex_items_genres_gin", "plex_items", ["genres"], postgresql_using="gin")
|
||||
op.create_index("ix_plex_items_directors_gin", "plex_items", ["directors"], postgresql_using="gin")
|
||||
op.create_index("ix_plex_items_cast_gin", "plex_items", ["cast_names"], postgresql_using="gin")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for ix in (
|
||||
"ix_plex_items_cast_gin",
|
||||
"ix_plex_items_directors_gin",
|
||||
"ix_plex_items_genres_gin",
|
||||
"ix_plex_items_originally_available_at",
|
||||
"ix_plex_items_studio",
|
||||
"ix_plex_items_content_rating",
|
||||
"ix_plex_items_rating",
|
||||
):
|
||||
op.drop_index(ix, table_name="plex_items")
|
||||
for col in (
|
||||
"cast_names",
|
||||
"directors",
|
||||
"genres",
|
||||
"originally_available_at",
|
||||
"studio",
|
||||
"content_rating",
|
||||
"rating",
|
||||
):
|
||||
op.drop_column("plex_items", col)
|
||||
|
|
@ -16,7 +16,7 @@ from sqlalchemy import (
|
|||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db import Base
|
||||
|
|
@ -995,6 +995,9 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|||
__tablename__ = "plex_items"
|
||||
__table_args__ = (
|
||||
Index("ix_plex_items_show_order", "show_id", "season_number", "episode_number"),
|
||||
Index("ix_plex_items_genres_gin", "genres", postgresql_using="gin"),
|
||||
Index("ix_plex_items_directors_gin", "directors", postgresql_using="gin"),
|
||||
Index("ix_plex_items_cast_gin", "cast_names", postgresql_using="gin"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
|
@ -1031,6 +1034,16 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
|
|||
# Intro/credit markers: [{"type": "intro"|"credits", "start_s": int, "end_s": int}, ...]
|
||||
markers: Mapped[list | None] = mapped_column(JSON)
|
||||
added_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||
# Filterable / orderable metadata mirrored from the Plex section listing (no extra API calls;
|
||||
# `rating` = Plex audienceRating, which is ~the IMDb score). The arrays back genre/person/studio
|
||||
# filters (GIN-indexed) and double as content signals for a future watch-habit recommender.
|
||||
rating: Mapped[float | None] = mapped_column(Float, index=True)
|
||||
content_rating: Mapped[str | None] = mapped_column(String(16), index=True)
|
||||
studio: Mapped[str | None] = mapped_column(String(255), index=True)
|
||||
originally_available_at: Mapped[Date | None] = mapped_column(Date, index=True)
|
||||
genres: Mapped[list | None] = mapped_column(JSONB)
|
||||
directors: Mapped[list | None] = mapped_column(JSONB)
|
||||
cast_names: Mapped[list | None] = mapped_column(JSONB)
|
||||
search_vector: Mapped[object | None] = mapped_column(
|
||||
TSVECTOR,
|
||||
Computed(
|
||||
|
|
|
|||
|
|
@ -56,6 +56,29 @@ def _epoch(v) -> datetime | None:
|
|||
return None
|
||||
|
||||
|
||||
def _date(v):
|
||||
"""Parse a Plex 'YYYY-MM-DD' string (originallyAvailableAt) → date, or None."""
|
||||
try:
|
||||
return datetime.strptime(v, "%Y-%m-%d").date() if v else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _tags(meta: dict, key: str, limit: int | None = None) -> list | None:
|
||||
"""The .tag values of a Plex child-tag array (Genre/Director/Role) present in the listing."""
|
||||
vals = [t.get("tag") for t in (meta.get(key) or []) if t.get("tag")]
|
||||
if limit:
|
||||
vals = vals[:limit]
|
||||
return vals or None
|
||||
|
||||
|
||||
def _rating(meta: dict) -> float | None:
|
||||
try:
|
||||
return float(meta.get("audienceRating") or meta.get("rating"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _media_facts(meta: dict) -> dict:
|
||||
media = meta.get("Media") or []
|
||||
if not media:
|
||||
|
|
@ -152,6 +175,14 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None:
|
|||
row.thumb_key = meta.get("thumb")
|
||||
row.art_key = meta.get("art") or meta.get("grandparentArt")
|
||||
row.added_at = _epoch(meta.get("addedAt"))
|
||||
# Filterable / orderable extras — all present in the cheap section listing.
|
||||
row.rating = _rating(meta)
|
||||
row.content_rating = (meta.get("contentRating") or None)
|
||||
row.studio = (meta.get("studio") or None) and str(meta.get("studio"))[:255]
|
||||
row.originally_available_at = _date(meta.get("originallyAvailableAt"))
|
||||
row.genres = _tags(meta, "Genre")
|
||||
row.directors = _tags(meta, "Director")
|
||||
row.cast_names = _tags(meta, "Role", limit=20)
|
||||
mf = _media_facts(meta)
|
||||
row.file_path = mf.get("file_path")
|
||||
row.part_key = mf.get("part_key")
|
||||
|
|
|
|||
|
|
@ -5,16 +5,21 @@ pure-Siftlode included) — the module is an access layer to the Plex library WI
|
|||
plex.tv account, and playback is a local file. Admin endpoints test the connection and run the
|
||||
catalog sync.
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy import and_, func, or_, text
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app import sysconfig
|
||||
from app.auth import current_user
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||
from app.plex import paths as plex_paths
|
||||
|
|
@ -167,6 +172,18 @@ def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _csv(v: str | None) -> list[str]:
|
||||
return [s.strip() for s in v.split(",") if s.strip()] if v else []
|
||||
|
||||
|
||||
_ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365}
|
||||
|
||||
|
||||
def _added_cutoff(within: str | None) -> datetime | None:
|
||||
days = _ADDED_WINDOWS.get(within or "")
|
||||
return datetime.now(timezone.utc) - timedelta(days=days) if days else None
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(
|
||||
library: str,
|
||||
|
|
@ -175,6 +192,19 @@ def browse(
|
|||
show: str = "all",
|
||||
offset: int = 0,
|
||||
limit: int = Query(default=40, ge=1, le=100),
|
||||
genres: str | None = None,
|
||||
genre_mode: str = "any",
|
||||
content_ratings: str | None = None,
|
||||
year_min: int | None = None,
|
||||
year_max: int | None = None,
|
||||
rating_min: float | None = None,
|
||||
duration_min: int | None = None,
|
||||
duration_max: int | None = None,
|
||||
added_within: str | None = None,
|
||||
directors: str | None = None,
|
||||
actors: str | None = None,
|
||||
studios: str | None = None,
|
||||
sort_dir: str = "desc",
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
|
|
@ -201,6 +231,34 @@ def browse(
|
|||
query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])))
|
||||
else: # all — hide only the explicitly hidden
|
||||
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
||||
# --- Metadata filters (movie libraries; @> containment is GIN-indexed) ---
|
||||
gsel = _csv(genres)
|
||||
if gsel:
|
||||
conds = [PlexItem.genres.contains([g]) for g in gsel]
|
||||
query = query.filter(and_(*conds) if genre_mode == "all" else or_(*conds))
|
||||
crs = _csv(content_ratings)
|
||||
if crs:
|
||||
query = query.filter(PlexItem.content_rating.in_(crs))
|
||||
if year_min is not None:
|
||||
query = query.filter(PlexItem.year >= year_min)
|
||||
if year_max is not None:
|
||||
query = query.filter(PlexItem.year <= year_max)
|
||||
if rating_min is not None:
|
||||
query = query.filter(PlexItem.rating >= rating_min)
|
||||
if duration_min is not None:
|
||||
query = query.filter(PlexItem.duration_s >= duration_min)
|
||||
if duration_max is not None:
|
||||
query = query.filter(PlexItem.duration_s <= duration_max)
|
||||
cutoff = _added_cutoff(added_within)
|
||||
if cutoff is not None:
|
||||
query = query.filter(PlexItem.added_at >= cutoff)
|
||||
for d in _csv(directors): # AND — titles this whole set directed
|
||||
query = query.filter(PlexItem.directors.contains([d]))
|
||||
for a in _csv(actors): # AND — titles featuring all these people
|
||||
query = query.filter(PlexItem.cast_names.contains([a]))
|
||||
stds = _csv(studios)
|
||||
if stds: # OR — a title has one studio
|
||||
query = query.filter(PlexItem.studio.in_(stds))
|
||||
else:
|
||||
query = query.filter(PlexShow.library_id == lib.id)
|
||||
|
||||
|
|
@ -214,10 +272,21 @@ def browse(
|
|||
|
||||
if tsq is not None:
|
||||
order = [func.ts_rank(model.search_vector, tsq).desc()]
|
||||
elif sort == "title":
|
||||
order = [func.lower(model.title).asc()]
|
||||
else: # newest first
|
||||
order = [model.added_at.desc().nullslast()]
|
||||
else:
|
||||
if sort == "title":
|
||||
col = func.lower(model.title)
|
||||
elif sort == "year" and model is PlexItem:
|
||||
col = PlexItem.year
|
||||
elif sort == "rating" and model is PlexItem:
|
||||
col = PlexItem.rating
|
||||
elif sort == "duration" and model is PlexItem:
|
||||
col = PlexItem.duration_s
|
||||
elif sort == "release" and model is PlexItem:
|
||||
col = PlexItem.originally_available_at
|
||||
else: # added
|
||||
col = model.added_at
|
||||
asc = sort_dir == "asc"
|
||||
order = [(col.asc() if asc else col.desc()).nullslast()]
|
||||
order.append(model.id.desc())
|
||||
|
||||
rows = query.order_by(*order).offset(offset).limit(limit).all()
|
||||
|
|
@ -237,6 +306,53 @@ def browse(
|
|||
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
|
||||
|
||||
|
||||
@router.get("/facets")
|
||||
def facets(
|
||||
library: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Available filter values for a movie library — genres + content ratings (with counts) and the
|
||||
year/rating/duration bounds — so the sidebar only offers what the library actually contains."""
|
||||
empty = {
|
||||
"genres": [],
|
||||
"content_ratings": [],
|
||||
"year_min": None,
|
||||
"year_max": None,
|
||||
"rating_max": None,
|
||||
"duration_min": None,
|
||||
"duration_max": None,
|
||||
}
|
||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
||||
if lib is None or lib.kind != "movie":
|
||||
return empty
|
||||
base = "FROM plex_items WHERE library_id = :lib AND kind = 'movie'"
|
||||
genres = db.execute(
|
||||
text(
|
||||
f"SELECT g, count(*) AS c FROM (SELECT jsonb_array_elements_text(genres) AS g {base}) x "
|
||||
"GROUP BY g ORDER BY c DESC, g"
|
||||
),
|
||||
{"lib": lib.id},
|
||||
).all()
|
||||
crs = db.execute(
|
||||
text(f"SELECT content_rating, count(*) AS c {base} AND content_rating IS NOT NULL GROUP BY content_rating ORDER BY c DESC"),
|
||||
{"lib": lib.id},
|
||||
).all()
|
||||
b = db.execute(
|
||||
text(f"SELECT min(year), max(year), max(rating), min(duration_s), max(duration_s) {base}"),
|
||||
{"lib": lib.id},
|
||||
).first()
|
||||
return {
|
||||
"genres": [{"value": g, "count": c} for g, c in genres],
|
||||
"content_ratings": [{"value": cr, "count": c} for cr, c in crs],
|
||||
"year_min": b[0],
|
||||
"year_max": b[1],
|
||||
"rating_max": float(b[2]) if b[2] is not None else None,
|
||||
"duration_min": b[3],
|
||||
"duration_max": b[4],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/show/{rating_key}")
|
||||
def show_detail(
|
||||
rating_key: str,
|
||||
|
|
@ -302,6 +418,42 @@ def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
# --- Thin on-disk image cache -----------------------------------------------------------------
|
||||
# Posters/art/cast-photos are proxied on demand; without a cache every scroll re-hits Plex over the
|
||||
# LAN (janky). We cache the fetched bytes on local disk keyed by a stable id, so repeat views (and
|
||||
# other users) serve instantly from disk. On-demand + bounded by the library size; no eviction (a
|
||||
# few hundred MB at most for a big library — acceptable, matching the download disk trade-offs).
|
||||
_IMG_CACHE = Path(settings.download_root) / ".plex-img-cache"
|
||||
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
||||
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
||||
|
||||
|
||||
def _img_cache_read(key: str) -> tuple[bytes, str] | None:
|
||||
for ext, ct in _EXT_CT:
|
||||
p = _IMG_CACHE / f"{key}.{ext}"
|
||||
try:
|
||||
if p.exists() and p.stat().st_size > 0:
|
||||
return p.read_bytes(), ct
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _img_cache_write(key: str, data: bytes, ct: str) -> None:
|
||||
ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg")
|
||||
try:
|
||||
_IMG_CACHE.mkdir(parents=True, exist_ok=True)
|
||||
tmp = _IMG_CACHE / f"{key}.{ext}.tmp"
|
||||
tmp.write_bytes(data)
|
||||
tmp.replace(_IMG_CACHE / f"{key}.{ext}") # atomic publish
|
||||
except OSError:
|
||||
pass # cache is best-effort
|
||||
|
||||
|
||||
def _img_response(data: bytes, ct: str) -> Response:
|
||||
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
|
||||
@router.get("/image/{rating_key}")
|
||||
def image(
|
||||
rating_key: str,
|
||||
|
|
@ -309,8 +461,14 @@ def image(
|
|||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication)."""
|
||||
key = _image_key(db, str(rating_key), "art" if variant == "art" else "thumb")
|
||||
"""Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A
|
||||
thin on-disk cache serves repeat views without re-hitting Plex."""
|
||||
v = "art" if variant == "art" else "thumb"
|
||||
cache_key = f"item_{rating_key}_{v}"
|
||||
hit = _img_cache_read(cache_key)
|
||||
if hit:
|
||||
return _img_response(*hit)
|
||||
key = _image_key(db, str(rating_key), v)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="No image")
|
||||
try:
|
||||
|
|
@ -320,7 +478,8 @@ def image(
|
|||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except PlexError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
||||
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
|
||||
_img_cache_write(cache_key, data, ct)
|
||||
return _img_response(data, ct)
|
||||
|
||||
|
||||
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
||||
|
|
@ -339,6 +498,84 @@ _PROGRESS_MIN_S = 5
|
|||
_FINISH_MARGIN_S = 30
|
||||
|
||||
|
||||
# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted)
|
||||
# so the user's browser makes no third-party request (privacy + self-host CSP friendliness).
|
||||
_PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
|
||||
|
||||
|
||||
def _rich_meta(meta: dict) -> dict:
|
||||
"""Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content
|
||||
rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any
|
||||
missing field is just omitted."""
|
||||
# Rating: prefer the explicit IMDb entry in the Rating array, else the generic audienceRating.
|
||||
imdb_rating = None
|
||||
for r in meta.get("Rating") or []:
|
||||
if str(r.get("image") or "").startswith("imdb://"):
|
||||
try:
|
||||
imdb_rating = round(float(r.get("value")), 1)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
break
|
||||
if imdb_rating is None:
|
||||
try:
|
||||
imdb_rating = round(float(meta.get("audienceRating") or meta.get("rating")), 1)
|
||||
except (TypeError, ValueError):
|
||||
imdb_rating = None
|
||||
# IMDb id (tt…) from the Guid list → a clickable link.
|
||||
imdb_id = None
|
||||
for g in meta.get("Guid") or []:
|
||||
gid = str(g.get("id") or "")
|
||||
if gid.startswith("imdb://"):
|
||||
imdb_id = gid.split("imdb://", 1)[1]
|
||||
break
|
||||
cast = []
|
||||
for role in (meta.get("Role") or [])[:24]:
|
||||
name = role.get("tag")
|
||||
if not name:
|
||||
continue
|
||||
thumb = str(role.get("thumb") or "")
|
||||
cast.append(
|
||||
{
|
||||
"name": name,
|
||||
"role": role.get("role"),
|
||||
"thumb": f"/api/plex/person-image?u={quote(thumb, safe='')}"
|
||||
if thumb.startswith(_PERSON_IMG_HOST)
|
||||
else None,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"imdb_rating": imdb_rating,
|
||||
"imdb_id": imdb_id,
|
||||
"imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None,
|
||||
"content_rating": meta.get("contentRating"),
|
||||
"genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8],
|
||||
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
|
||||
"studio": meta.get("studio"),
|
||||
"tagline": meta.get("tagline"),
|
||||
"cast": cast,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/person-image")
|
||||
def person_image(u: str, _: User = Depends(current_user)) -> Response:
|
||||
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed)."""
|
||||
if not u.startswith(_PERSON_IMG_HOST):
|
||||
raise HTTPException(status_code=400, detail="Unsupported image host")
|
||||
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
||||
hit = _img_cache_read(cache_key)
|
||||
if hit:
|
||||
return _img_response(*hit)
|
||||
try:
|
||||
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
|
||||
r = c.get(u)
|
||||
r.raise_for_status()
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=502, detail="Image fetch failed")
|
||||
ct = r.headers.get("content-type", "image/jpeg")
|
||||
_img_cache_write(cache_key, r.content, ct)
|
||||
return _img_response(r.content, ct)
|
||||
|
||||
|
||||
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
|
||||
if it.kind != "episode" or it.show_id is None:
|
||||
return None, None
|
||||
|
|
@ -367,14 +604,14 @@ def item_detail(
|
|||
it = _item_or_404(db, rating_key)
|
||||
st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first()
|
||||
|
||||
cast: list[str] = []
|
||||
rich: dict = {}
|
||||
markers: list[dict] = []
|
||||
audio_streams: list[dict] = []
|
||||
subtitle_streams: list[dict] = []
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
meta = plex.metadata(it.rating_key, markers=True) or {}
|
||||
cast = [r.get("tag") for r in (meta.get("Role") or []) if r.get("tag")][:12]
|
||||
rich = _rich_meta(meta)
|
||||
for m in meta.get("Marker") or []:
|
||||
if m.get("type") in ("intro", "credits"):
|
||||
markers.append(
|
||||
|
|
@ -419,7 +656,15 @@ def item_detail(
|
|||
"playable": it.playable,
|
||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||
"art": f"/api/plex/image/{it.rating_key}?variant=art",
|
||||
"cast": cast,
|
||||
"cast": rich.get("cast", []),
|
||||
"imdb_rating": rich.get("imdb_rating"),
|
||||
"imdb_id": rich.get("imdb_id"),
|
||||
"imdb_url": rich.get("imdb_url"),
|
||||
"content_rating": rich.get("content_rating"),
|
||||
"genres": rich.get("genres", []),
|
||||
"directors": rich.get("directors", []),
|
||||
"studio": rich.get("studio"),
|
||||
"tagline": rich.get("tagline"),
|
||||
"markers": markers,
|
||||
"audio_streams": audio_streams,
|
||||
"subtitle_streams": subtitle_streams,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
api,
|
||||
EMPTY_PLEX_FILTERS,
|
||||
getActiveAccount,
|
||||
setActiveAccount,
|
||||
setUnauthorizedHandler,
|
||||
type FeedFilters,
|
||||
type PlexFilters,
|
||||
} from "./lib/api";
|
||||
import { setLanguage, isSupported, type LangCode } from "./i18n";
|
||||
import {
|
||||
|
|
@ -247,6 +249,16 @@ export default function App() {
|
|||
const [plexLib, setPlexLib] = useAccountPersistedState(LS.plexLibrary, "");
|
||||
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
|
||||
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "added");
|
||||
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
|
||||
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
|
||||
const plexFilters = useMemo<PlexFilters>(() => {
|
||||
try {
|
||||
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(plexFiltersRaw) };
|
||||
} catch {
|
||||
return EMPTY_PLEX_FILTERS;
|
||||
}
|
||||
}, [plexFiltersRaw]);
|
||||
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
|
||||
// Bumped to tell the channel manager to drop a stale column filter when we send the user
|
||||
// there to see a specific set (the header's "without full history" link).
|
||||
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
|
||||
|
|
@ -727,6 +739,8 @@ export default function App() {
|
|||
setShow={setPlexShowFilter}
|
||||
sort={plexSort}
|
||||
setSort={setPlexSort}
|
||||
filters={plexFilters}
|
||||
setFilters={setPlexFilters}
|
||||
collapsed={filterCollapsed}
|
||||
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
|
||||
/>
|
||||
|
|
@ -802,7 +816,14 @@ export default function App() {
|
|||
onOpenWizard={() => setWizardOpen(true)}
|
||||
/>
|
||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||
<PlexBrowse q={filters.q} library={plexLib} show={plexShowFilter} sort={plexSort} />
|
||||
<PlexBrowse
|
||||
q={filters.q}
|
||||
library={plexLib}
|
||||
show={plexShowFilter}
|
||||
sort={plexSort}
|
||||
filters={plexFilters}
|
||||
setFilters={setPlexFilters}
|
||||
/>
|
||||
) : (
|
||||
<Feed
|
||||
filters={filters}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, CheckCircle2, Play } from "lucide-react";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { ArrowLeft, CheckCircle2, Info, Play } from "lucide-react";
|
||||
import { api, type PlexCard, type PlexFilters } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
|
||||
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
|
||||
const PlexPlayer = lazy(() => import("./PlexPlayer"));
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
|
||||
type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; id: string };
|
||||
type Sub =
|
||||
| { kind: "grid" }
|
||||
| { kind: "show"; id: string }
|
||||
| { kind: "player"; id: string }
|
||||
| { kind: "info"; id: string };
|
||||
|
||||
// The Plex module's content area: browse/search the mirrored library as poster cards, drill from a
|
||||
// show into seasons/episodes. Library scope / watch-state / sort live in the left PlexSidebar; the
|
||||
|
|
@ -17,7 +22,14 @@ type Sub = { kind: "grid" } | { kind: "show"; id: string } | { kind: "player"; i
|
|||
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
|
||||
// for now. Rendered by App for page==="plex" (its own nav module).
|
||||
|
||||
type Props = { q: string; library: string; show: string; sort: string };
|
||||
type Props = {
|
||||
q: string;
|
||||
library: string;
|
||||
show: string;
|
||||
sort: string;
|
||||
filters: PlexFilters;
|
||||
setFilters: (f: PlexFilters) => void;
|
||||
};
|
||||
|
||||
const PAGE = 40;
|
||||
|
||||
|
|
@ -28,7 +40,7 @@ function dur(n?: number | null): string {
|
|||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
export default function PlexBrowse({ q, library, show, sort }: Props) {
|
||||
export default function PlexBrowse({ q, library, show, sort, filters, setFilters }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const dq = useDebounced(q.trim(), 350);
|
||||
|
|
@ -48,11 +60,19 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
|||
}, [sub.view.kind]);
|
||||
|
||||
const browseQ = useInfiniteQuery({
|
||||
queryKey: ["plex-browse", library, dq, sort, show],
|
||||
queryKey: ["plex-browse", library, dq, sort, show, filters],
|
||||
enabled: !!library && sub.view.kind === "grid",
|
||||
initialPageParam: 0,
|
||||
queryFn: ({ pageParam }) =>
|
||||
api.plexBrowse({ library, q: dq || undefined, sort, show, offset: pageParam as number, limit: PAGE }),
|
||||
api.plexBrowse({
|
||||
library,
|
||||
q: dq || undefined,
|
||||
sort,
|
||||
show,
|
||||
filters,
|
||||
offset: pageParam as number,
|
||||
limit: PAGE,
|
||||
}),
|
||||
getNextPageParam: (last) => {
|
||||
const seen = last.offset + last.items.length;
|
||||
return seen < last.total ? seen : undefined;
|
||||
|
|
@ -83,6 +103,10 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
|||
if (card.type === "show") sub.open({ kind: "show", id: card.id });
|
||||
else sub.open({ kind: "player", id: card.id });
|
||||
}
|
||||
function onInfo(card: PlexCard) {
|
||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||
sub.open({ kind: "info", id: card.id });
|
||||
}
|
||||
async function toggleWatched(card: PlexCard) {
|
||||
await api.plexSetState(card.id, card.status === "watched" ? "new" : "watched");
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
|
|
@ -95,6 +119,33 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
|||
</Suspense>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "info") {
|
||||
const infoId = sub.view.id;
|
||||
return (
|
||||
<PlexInfoView
|
||||
id={infoId}
|
||||
onBack={sub.back}
|
||||
onPlay={() => sub.open({ kind: "player", id: infoId })}
|
||||
onFilter={(patch) => {
|
||||
// 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
|
||||
// replace. We push a fresh grid entry (not history.back) so browser Back returns to this
|
||||
// info page instead of leaving the Plex module.
|
||||
const merged = { ...filters } as unknown as Record<string, unknown>;
|
||||
for (const [k, v] of Object.entries(patch)) {
|
||||
const cur = merged[k];
|
||||
if (Array.isArray(v) && Array.isArray(cur)) {
|
||||
merged[k] = Array.from(new Set([...(cur as unknown[]), ...(v as unknown[])]));
|
||||
} else {
|
||||
merged[k] = v;
|
||||
}
|
||||
}
|
||||
setFilters(merged as unknown as PlexFilters);
|
||||
sub.open({ kind: "grid" });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (sub.view.kind === "show") {
|
||||
return (
|
||||
<PlexShowView
|
||||
|
|
@ -118,7 +169,13 @@ export default function PlexBrowse({ q, library, show, sort }: Props) {
|
|||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
|
||||
{items.map((c) => (
|
||||
<PlexPosterCard key={c.id} card={c} onOpen={() => onCard(c)} onToggleWatched={toggleWatched} />
|
||||
<PlexPosterCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
onOpen={() => onCard(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
onToggleWatched={toggleWatched}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -138,10 +195,12 @@ const PLAYABLE_TINT: Record<string, string> = {
|
|||
function PlexPosterCard({
|
||||
card,
|
||||
onOpen,
|
||||
onInfo,
|
||||
onToggleWatched,
|
||||
}: {
|
||||
card: PlexCard;
|
||||
onOpen: () => void;
|
||||
onInfo: () => void;
|
||||
onToggleWatched: (c: PlexCard) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -200,6 +259,19 @@ function PlexPosterCard({
|
|||
<CheckCircle2 className={`w-4 h-4 ${card.status === "watched" ? "text-emerald-400" : "text-white"}`} />
|
||||
</button>
|
||||
)}
|
||||
{/* Media info page (movies/episodes). */}
|
||||
{isPlayable && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onInfo();
|
||||
}}
|
||||
title={t("plex.info.title")}
|
||||
className="absolute bottom-1.5 left-1.5 p-1 rounded-full bg-black/60 opacity-0 group-hover:opacity-100 hover:bg-black/80 transition"
|
||||
>
|
||||
<Info className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
)}
|
||||
{/* Watched badge when not hovering (the toggle replaces it on hover). */}
|
||||
{card.status === "watched" && (
|
||||
<span className="absolute top-1.5 right-1.5 text-[10px] bg-black/70 text-white px-1.5 py-0.5 rounded group-hover:opacity-0">
|
||||
|
|
@ -229,6 +301,41 @@ function PlexPosterCard({
|
|||
);
|
||||
}
|
||||
|
||||
function PlexInfoView({
|
||||
id,
|
||||
onBack,
|
||||
onPlay,
|
||||
onFilter,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onPlay: () => void;
|
||||
onFilter: (patch: Partial<PlexFilters>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
|
||||
return (
|
||||
<div className="max-w-[1100px] mx-auto">
|
||||
<div className="p-4 pb-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("plex.backToLibrary")}
|
||||
</button>
|
||||
</div>
|
||||
{q.isLoading || !q.data ? (
|
||||
<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} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlexShowView({
|
||||
showId,
|
||||
onBack,
|
||||
|
|
|
|||
370
frontend/src/components/PlexInfo.tsx
Normal file
370
frontend/src/components/PlexInfo.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ExternalLink,
|
||||
Play,
|
||||
RotateCcw,
|
||||
SlidersHorizontal,
|
||||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { api, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
||||
|
||||
// The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating,
|
||||
// genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from
|
||||
// a card's "i" button (variant="page", with a big Play/Resume + watch controls), and a lean overlay
|
||||
// over the running player (variant="overlay", opened with "i"). Two GUI elements are user-toggleable
|
||||
// personal preferences, persisted in users.preferences: the faint art backdrop and the cast row.
|
||||
|
||||
type Props = {
|
||||
detail: PlexItemDetail;
|
||||
variant: "page" | "overlay";
|
||||
onPlay?: () => void; // page: start/resume playback
|
||||
onClose?: () => void; // overlay: dismiss
|
||||
onStateChange?: () => void; // after a watch-state change, let the opener refresh
|
||||
// When provided, metadata (year/genre/director/cast/studio/rating) becomes clickable and sets
|
||||
// the corresponding Plex filter (then the opener navigates to the filtered grid).
|
||||
onFilter?: (patch: Partial<PlexFilters>) => void;
|
||||
};
|
||||
|
||||
// A metadata value that filters the grid when clicked (if onFilter is wired), else plain text.
|
||||
function Filterable({
|
||||
onClick,
|
||||
title,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (!onClick) return <span className={className}>{children}</span>;
|
||||
return (
|
||||
<button onClick={onClick} title={title} className={`${className ?? ""} cursor-pointer hover:text-accent transition`}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDur(s?: number | null): string {
|
||||
if (!s) return "";
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
return h ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
export default function PlexInfo({ detail, variant, onPlay, onClose, onStateChange, onFilter }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Personal display prefs (default ON). Stored per-user; the backend merges any pref key.
|
||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
||||
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean };
|
||||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
||||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
||||
const [customizing, setCustomizing] = useState(false);
|
||||
const savePref = (patch: Record<string, boolean>) => {
|
||||
api.savePrefs(patch).catch(() => {});
|
||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||
);
|
||||
};
|
||||
|
||||
const inProgress = (detail.position_seconds ?? 0) > 0 && detail.status !== "watched";
|
||||
const setState = async (status: "new" | "watched") => {
|
||||
await api.plexSetState(detail.id, status).catch(() => {});
|
||||
qc.invalidateQueries({ queryKey: ["plex-item", detail.id] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-browse"] });
|
||||
qc.invalidateQueries({ queryKey: ["plex-show"] });
|
||||
onStateChange?.();
|
||||
};
|
||||
|
||||
const cast = showCast ? detail.cast : [];
|
||||
const overlay = variant === "overlay";
|
||||
const mutedCls = overlay ? "text-white/70" : "text-muted";
|
||||
|
||||
return (
|
||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}>
|
||||
{/* Faint art backdrop (page only, toggleable). */}
|
||||
{!overlay && artBg && detail.art && (
|
||||
<div className="absolute inset-0 -z-10 overflow-hidden rounded-2xl">
|
||||
<img src={detail.art} alt="" className="w-full h-full object-cover opacity-20" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-bg via-bg/85 to-bg/60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={overlay ? "p-5" : "p-4 sm:p-6"}>
|
||||
{/* Header: close (overlay) / customize */}
|
||||
<div className="mb-4 flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className={overlay ? "text-lg font-bold text-white" : "text-2xl font-bold"}>
|
||||
{detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title}
|
||||
</h1>
|
||||
{detail.kind === "episode" && (
|
||||
<p className={`text-sm ${overlay ? "text-white/70" : "text-muted"}`}>
|
||||
S{detail.season_number}·E{detail.episode_number} — {detail.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative shrink-0">
|
||||
<button
|
||||
onClick={() => setCustomizing((v) => !v)}
|
||||
title={t("plex.info.customize")}
|
||||
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
{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">
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefArtBg")}
|
||||
on={artBg}
|
||||
onClick={() => {
|
||||
setArtBg((v) => !v);
|
||||
savePref({ plexInfoArtBg: !artBg });
|
||||
}}
|
||||
/>
|
||||
<PrefToggle
|
||||
label={t("plex.info.prefCast")}
|
||||
on={showCast}
|
||||
onClick={() => {
|
||||
setShowCast((v) => !v);
|
||||
savePref({ plexInfoCast: !showCast });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{overlay && onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 p-1.5 rounded-lg text-white/80 hover:bg-white/15"
|
||||
aria-label={t("plex.player.back")}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:gap-6">
|
||||
{/* Poster */}
|
||||
<img
|
||||
src={detail.thumb}
|
||||
alt=""
|
||||
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-[2/3]`}
|
||||
/>
|
||||
|
||||
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
||||
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
{detail.year != null && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
|
||||
onClick={onFilter ? () => onFilter({ yearMin: detail.year, yearMax: detail.year }) : undefined}
|
||||
>
|
||||
{detail.year}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.content_rating && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
onClick={onFilter ? () => onFilter({ contentRatings: [detail.content_rating!] }) : undefined}
|
||||
>
|
||||
{detail.content_rating}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.duration_seconds ? <span className={mutedCls}>{fmtDur(detail.duration_seconds)}</span> : null}
|
||||
{detail.studio && (
|
||||
<Filterable
|
||||
className={mutedCls}
|
||||
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
||||
>
|
||||
{detail.studio}
|
||||
</Filterable>
|
||||
)}
|
||||
{detail.imdb_rating != null && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-amber-400/15 px-1.5 py-0.5 font-semibold text-amber-500">
|
||||
<button
|
||||
onClick={onFilter ? () => onFilter({ ratingMin: Math.floor(detail.imdb_rating!) }) : undefined}
|
||||
className={`inline-flex items-center gap-1 ${onFilter ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
|
||||
title={onFilter ? t("plex.info.filterRating", { n: Math.floor(detail.imdb_rating!) }) : undefined}
|
||||
>
|
||||
<Star className="w-3.5 h-3.5 fill-current" />
|
||||
{detail.imdb_rating.toFixed(1)}
|
||||
</button>
|
||||
{detail.imdb_url && (
|
||||
<a href={detail.imdb_url} target="_blank" rel="noopener noreferrer" title={t("plex.info.openImdb")}>
|
||||
<ExternalLink className="w-3 h-3 opacity-70 hover:opacity-100" />
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail.tagline && (
|
||||
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
|
||||
{detail.tagline}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detail.genres.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{detail.genres.map((g) => (
|
||||
<Filterable
|
||||
key={g}
|
||||
className={`rounded-full px-2 py-0.5 text-xs ${overlay ? "bg-white/10" : "bg-surface text-muted"}`}
|
||||
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||
>
|
||||
{g}
|
||||
</Filterable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.directors.length > 0 && (
|
||||
<p className={`mt-2 text-sm ${mutedCls}`}>
|
||||
{t("plex.info.director")}:{" "}
|
||||
{detail.directors.map((d, i) => (
|
||||
<span key={d}>
|
||||
<Filterable
|
||||
className="font-medium"
|
||||
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
||||
>
|
||||
{d}
|
||||
</Filterable>
|
||||
{i < detail.directors.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{detail.summary && (
|
||||
<p className={`mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}>
|
||||
{detail.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Page actions: Play/Resume + watch controls */}
|
||||
{!overlay && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
{onPlay && (
|
||||
<button
|
||||
onClick={onPlay}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-white hover:opacity-90"
|
||||
>
|
||||
<Play className="w-5 h-5 fill-current" />
|
||||
{inProgress ? t("plex.resume") : t("plex.play")}
|
||||
</button>
|
||||
)}
|
||||
{detail.status === "watched" ? (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t("plex.info.markUnwatched")}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{t("plex.info.markWatched")}
|
||||
</button>
|
||||
)}
|
||||
{inProgress && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t("plex.info.clearResume")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cast row (toggleable), circular photos. */}
|
||||
{cast.length > 0 && (
|
||||
<div className="mt-5">
|
||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||
{t("plex.info.cast")}
|
||||
</h2>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{cast.map((c, i) => {
|
||||
const inner = (
|
||||
<>
|
||||
<div
|
||||
className={`mx-auto mb-1 h-20 w-20 overflow-hidden rounded-full border ${
|
||||
overlay ? "border-white/15 bg-white/10" : "border-border bg-surface"
|
||||
} ${onFilter ? "group-hover/cast:border-accent" : ""}`}
|
||||
>
|
||||
{c.thumb ? (
|
||||
<img src={c.thumb} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-lg opacity-40">
|
||||
{c.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`truncate text-xs font-medium ${overlay ? "text-white/90" : ""} ${
|
||||
onFilter ? "group-hover/cast:text-accent" : ""
|
||||
}`}
|
||||
>
|
||||
{c.name}
|
||||
</div>
|
||||
{c.role && (
|
||||
<div className={`truncate text-[11px] ${overlay ? "text-white/55" : "text-muted"}`}>
|
||||
{c.role}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return onFilter ? (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => onFilter({ actors: [c.name] })}
|
||||
title={t("plex.info.filterActor", { name: c.name })}
|
||||
className="group/cast w-20 shrink-0 text-center"
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<div key={i} className="w-20 shrink-0 text-center">
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrefToggle({ label, on, onClick }: { label: string; on: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left hover:bg-surface"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span
|
||||
className={`relative h-4 w-7 rounded-full transition ${on ? "bg-accent" : "bg-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white transition-all ${on ? "left-3.5" : "left-0.5"}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Fragment, lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Hls from "hls.js";
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
ArrowLeft,
|
||||
Clock,
|
||||
Download,
|
||||
Info,
|
||||
Keyboard,
|
||||
Maximize,
|
||||
Minimize,
|
||||
|
|
@ -22,6 +23,9 @@ import {
|
|||
} from "lucide-react";
|
||||
import { api, type PlexMarker } from "../lib/api";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
|
||||
// The Plex module's rich, full-page player. Plays the LOCAL file: direct-playable files stream raw
|
||||
// (native <video>), remux files stream via the seek-restart HLS session (hls.js). The visible
|
||||
// timeline is ABSOLUTE (0…duration); the HLS session is relative to its start offset, so we map
|
||||
|
|
@ -49,6 +53,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
// False once unmounted, so a late play() (from MANIFEST_PARSED / loadedmetadata firing after Back)
|
||||
// can't start a detached <video> playing audio in the background.
|
||||
const aliveRef = useRef(true);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const sessionStartRef = useRef(0); // absolute offset the current HLS/direct session begins at
|
||||
const durationRef = useRef(0);
|
||||
|
|
@ -77,6 +84,10 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const helpOpenRef = useRef(false);
|
||||
helpOpenRef.current = helpOpen;
|
||||
// "I" toggles a rich info overlay (poster/cast/ratings) over the running video.
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const infoOpenRef = useRef(false);
|
||||
infoOpenRef.current = infoOpen;
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
useEffect(() => {
|
||||
const iv = window.setInterval(() => setNow(new Date()), 15000);
|
||||
|
|
@ -135,7 +146,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
enableSubs();
|
||||
setReady(true);
|
||||
video.play().catch(() => {});
|
||||
if (aliveRef.current) video.play().catch(() => {});
|
||||
});
|
||||
// A fatal HLS error (e.g. the remux session died / the file became unreadable) would
|
||||
// otherwise leave the spinner up forever — surface it instead.
|
||||
|
|
@ -149,7 +160,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
const onMeta = () => {
|
||||
if (sess.mode === "direct" && startAt > 0) video.currentTime = startAt;
|
||||
setReady(true);
|
||||
video.play().catch(() => {});
|
||||
if (aliveRef.current) video.play().catch(() => {});
|
||||
video.removeEventListener("loadedmetadata", onMeta);
|
||||
};
|
||||
video.addEventListener("loadedmetadata", onMeta);
|
||||
|
|
@ -171,6 +182,28 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
};
|
||||
}, [detail, loadSession]);
|
||||
|
||||
// Full media teardown on unmount (Back): stop + detach the <video> and destroy hls, so a late
|
||||
// play() can't leave audio playing on a detached element after leaving the player.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
const v = videoRef.current;
|
||||
if (v) {
|
||||
try {
|
||||
v.pause();
|
||||
v.removeAttribute("src");
|
||||
v.load();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- time tracking + progress checkpoints ----------------------------------------------------
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
|
|
@ -409,14 +442,20 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
case "H":
|
||||
setHelpOpen((v) => !v);
|
||||
break;
|
||||
case "i":
|
||||
case "I":
|
||||
setInfoOpen((v) => !v);
|
||||
break;
|
||||
case "Backspace":
|
||||
// HTPC-style "stop & back to the feed" — mirrors the mouse Back button.
|
||||
e.preventDefault();
|
||||
if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else onClose();
|
||||
break;
|
||||
case "Escape":
|
||||
if (helpOpenRef.current) setHelpOpen(false);
|
||||
else if (infoOpenRef.current) setInfoOpen(false);
|
||||
else if (!document.fullscreenElement) onClose();
|
||||
break;
|
||||
}
|
||||
|
|
@ -637,6 +676,9 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<Ctrl label={t("plex.player.infoBtn")} onClick={() => setInfoOpen((v) => !v)}>
|
||||
<Info className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
<Ctrl label={t("plex.player.help")} onClick={() => setHelpOpen((v) => !v)}>
|
||||
<Keyboard className="w-5 h-5" />
|
||||
</Ctrl>
|
||||
|
|
@ -686,6 +728,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
[["↑", "↓"], "plex.player.keys.volume"],
|
||||
[["M"], "plex.player.keys.mute"],
|
||||
[["F"], "plex.player.keys.fullscreen"],
|
||||
[["I"], "plex.player.keys.info"],
|
||||
[["⌫", "Esc"], "plex.player.keys.back"],
|
||||
[["H"], "plex.player.keys.help"],
|
||||
] as [string[], string][]
|
||||
|
|
@ -708,6 +751,26 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
|
||||
{infoOpen && detail && (
|
||||
<div
|
||||
className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-black/70 p-4"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setInfoOpen(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="my-auto w-full max-w-3xl rounded-2xl border border-white/15 bg-neutral-900/95 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<PlexInfo detail={detail} variant="overlay" onClose={() => setInfoOpen(false)} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
import { useEffect } from "react";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Film, SlidersHorizontal, Tv2 } from "lucide-react";
|
||||
import { api } from "../lib/api";
|
||||
import { ChevronRight, Film, SlidersHorizontal, Tv2, X } from "lucide-react";
|
||||
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
|
||||
|
||||
// The Plex module's left filter column (mirrors the feed Sidebar's shell): pick a library, filter
|
||||
// by watch state (movie libraries only), and sort. State is owned by App (per-account persisted).
|
||||
// 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
|
||||
// director/actor/studio chips set by clicking the info page); shows keep library + sort only. State
|
||||
// is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the
|
||||
// backend so the sidebar only offers what the library actually contains.
|
||||
|
||||
type Props = {
|
||||
library: string;
|
||||
|
|
@ -14,12 +17,23 @@ type Props = {
|
|||
setShow: (v: string) => void;
|
||||
sort: string;
|
||||
setSort: (v: string) => void;
|
||||
filters: PlexFilters;
|
||||
setFilters: (f: PlexFilters) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
const SORT_OPTS = ["added", "title"] as const;
|
||||
const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const;
|
||||
const SHOW_SORTS = ["added", "title"] as const;
|
||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||
// Duration buckets → [minSeconds|null, maxSeconds|null].
|
||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||||
{ key: "short", min: null, max: 90 * 60 },
|
||||
{ key: "mid", min: 90 * 60, max: 120 * 60 },
|
||||
{ key: "long", min: 120 * 60, max: null },
|
||||
];
|
||||
|
||||
export default function PlexSidebar({
|
||||
library,
|
||||
|
|
@ -28,6 +42,8 @@ export default function PlexSidebar({
|
|||
setShow,
|
||||
sort,
|
||||
setSort,
|
||||
filters,
|
||||
setFilters,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: Props) {
|
||||
|
|
@ -35,14 +51,38 @@ export default function PlexSidebar({
|
|||
const libsQ = useQuery({ queryKey: ["plex-libraries"], queryFn: api.plexLibraries });
|
||||
const libs = libsQ.data?.libraries ?? [];
|
||||
const activeLib = libs.find((l) => l.key === library);
|
||||
const isMovieLib = activeLib?.kind === "movie";
|
||||
|
||||
const facetsQ = useQuery({
|
||||
queryKey: ["plex-facets", library],
|
||||
queryFn: () => api.plexFacets(library),
|
||||
enabled: !!library && isMovieLib,
|
||||
});
|
||||
const facets = facetsQ.data;
|
||||
|
||||
// Default to the first library once loaded (or if the stored one vanished).
|
||||
useEffect(() => {
|
||||
if (libs.length && !libs.some((l) => l.key === library)) setLibrary(libs[0].key);
|
||||
}, [libs, library, setLibrary]);
|
||||
|
||||
const isMovieLib = activeLib?.kind === "movie";
|
||||
const activeCount = (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||||
const fCount = isMovieLib ? plexFilterCount(filters) : 0;
|
||||
const activeCount =
|
||||
fCount + (show !== "all" && isMovieLib ? 1 : 0) + (sort !== "added" ? 1 : 0);
|
||||
const anyActive = activeCount > 0;
|
||||
|
||||
const patch = (p: Partial<PlexFilters>) => setFilters({ ...filters, ...p });
|
||||
const toggleIn = (arr: string[], v: string) =>
|
||||
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
|
||||
const clearAll = () => {
|
||||
setFilters(EMPTY_PLEX_FILTERS);
|
||||
setShow("all");
|
||||
setSort("added");
|
||||
};
|
||||
|
||||
const sorts = isMovieLib ? MOVIE_SORTS : SHOW_SORTS;
|
||||
const durBucketKey = DURATION_BUCKETS.find(
|
||||
(b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max,
|
||||
)?.key;
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
|
|
@ -74,8 +114,7 @@ export default function PlexSidebar({
|
|||
return (
|
||||
<aside className="hidden md:block w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-4">
|
||||
{/* Library scope */}
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.library")}</div>
|
||||
<Section label={t("plex.filter.library")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
{libs.map((l) => (
|
||||
<button
|
||||
|
|
@ -91,45 +130,268 @@ export default function PlexSidebar({
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Watch state (movie libraries only — an episode's state lives in the show drill-down) */}
|
||||
{isMovieLib && (
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.show")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{SHOW_OPTS.map((s) => (
|
||||
{anyActive && (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setShow(s)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||||
show === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
onClick={clearAll}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
{t(`plex.filter.showOpt.${s}`)}
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t("plex.filter.clearAll")} ({activeCount})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort */}
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{t("plex.filter.sort")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{SORT_OPTS.map((s) => (
|
||||
{/* Watch state (movie libraries only) */}
|
||||
{isMovieLib && (
|
||||
<Section label={t("plex.filter.show")}>
|
||||
<ChipRow>
|
||||
{SHOW_OPTS.map((s) => (
|
||||
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
|
||||
{t(`plex.filter.showOpt.${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Sort + direction */}
|
||||
<Section label={t("plex.filter.sort")}>
|
||||
<ChipRow>
|
||||
{sorts.map((s) => (
|
||||
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
||||
{t(`plex.sort.${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
<div className="mt-1.5 flex gap-1">
|
||||
{(["desc", "asc"] as const).map((d) => (
|
||||
<Chip key={d} active={(filters.sortDir ?? "desc") === d} onClick={() => patch({ sortDir: d })}>
|
||||
{t(`plex.filter.dir.${d}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Metadata filters (movie libraries only) */}
|
||||
{isMovieLib && (
|
||||
<>
|
||||
{/* Active people / studios — set by clicking the info page (stackable). */}
|
||||
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
|
||||
<Section label={t("plex.filter.active")}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filters.directors.map((d) => (
|
||||
<RemovableChip
|
||||
key={`d:${d}`}
|
||||
label={d}
|
||||
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
|
||||
/>
|
||||
))}
|
||||
{filters.actors.map((a) => (
|
||||
<RemovableChip
|
||||
key={`a:${a}`}
|
||||
label={a}
|
||||
onRemove={() => patch({ actors: filters.actors.filter((x) => x !== a) })}
|
||||
/>
|
||||
))}
|
||||
{filters.studios.map((s) => (
|
||||
<RemovableChip
|
||||
key={`s:${s}`}
|
||||
label={s}
|
||||
onRemove={() => patch({ studios: filters.studios.filter((x) => x !== s) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* IMDb rating min */}
|
||||
<Section label={t("plex.filter.rating")}>
|
||||
<ChipRow>
|
||||
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{RATING_STEPS.map((r) => (
|
||||
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
||||
{r}+
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Genres (from facets) + Any/All when more than one is picked */}
|
||||
{facets && facets.genres.length > 0 && (
|
||||
<Section label={t("plex.filter.genre")}>
|
||||
{filters.genres.length > 1 && (
|
||||
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
|
||||
{(["any", "all"] as const).map((m) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setSort(s)}
|
||||
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||||
sort === s ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
key={m}
|
||||
onClick={() => patch({ genreMode: m })}
|
||||
className={`px-2 py-0.5 transition ${
|
||||
(filters.genreMode ?? "any") === m
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-muted hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{t(`plex.sort.${s}`)}
|
||||
{t(`plex.filter.match.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChipRow>
|
||||
{facets.genres.map((g) => (
|
||||
<Chip
|
||||
key={g.value}
|
||||
active={filters.genres.includes(g.value)}
|
||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||
>
|
||||
{g.value}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Year range */}
|
||||
<Section label={t("plex.filter.year")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
value={filters.yearMin ?? null}
|
||||
placeholder={facets?.year_min ?? undefined}
|
||||
onChange={(v) => patch({ yearMin: v })}
|
||||
/>
|
||||
<span className="text-muted text-xs">–</span>
|
||||
<NumberInput
|
||||
value={filters.yearMax ?? null}
|
||||
placeholder={facets?.year_max ?? undefined}
|
||||
onChange={(v) => patch({ yearMax: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Duration buckets */}
|
||||
<Section label={t("plex.filter.duration")}>
|
||||
<ChipRow>
|
||||
<Chip
|
||||
active={!durBucketKey && filters.durationMin == null && filters.durationMax == null}
|
||||
onClick={() => patch({ durationMin: null, durationMax: null })}
|
||||
>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{DURATION_BUCKETS.map((b) => (
|
||||
<Chip
|
||||
key={b.key}
|
||||
active={durBucketKey === b.key}
|
||||
onClick={() => patch({ durationMin: b.min, durationMax: b.max })}
|
||||
>
|
||||
{t(`plex.filter.durationOpt.${b.key}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Added to Plex */}
|
||||
<Section label={t("plex.filter.added")}>
|
||||
<ChipRow>
|
||||
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{ADDED_OPTS.map((a) => (
|
||||
<Chip key={a} active={filters.addedWithin === a} onClick={() => patch({ addedWithin: a })}>
|
||||
{t(`plex.filter.addedOpt.${a}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Content rating (from facets) */}
|
||||
{facets && facets.content_ratings.length > 0 && (
|
||||
<Section label={t("plex.filter.contentRating")}>
|
||||
<ChipRow>
|
||||
{facets.content_ratings.map((c) => (
|
||||
<Chip
|
||||
key={c.value}
|
||||
active={filters.contentRatings.includes(c.value)}
|
||||
onClick={() => patch({ contentRatings: toggleIn(filters.contentRatings, c.value) })}
|
||||
>
|
||||
{c.value}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipRow({ children }: { children: ReactNode }) {
|
||||
return <div className="flex flex-wrap gap-1">{children}</div>;
|
||||
}
|
||||
|
||||
function Chip({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`px-2.5 py-1 rounded-full text-xs transition ${
|
||||
active ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs">
|
||||
<span className="truncate max-w-[9rem]">{label}</span>
|
||||
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: number | null;
|
||||
placeholder?: number;
|
||||
onChange: (v: number | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder != null ? String(placeholder) : ""}
|
||||
onChange={(e) => {
|
||||
const n = e.target.value === "" ? null : Number(e.target.value);
|
||||
onChange(n != null && Number.isFinite(n) ? n : null);
|
||||
}}
|
||||
className="w-20 rounded-lg border border-border bg-card px-2 py-1 text-sm tabular-nums focus:border-accent focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"searchPlaceholder": "Plex durchsuchen…",
|
||||
"sort": {
|
||||
"added": "Kürzlich hinzugefügt",
|
||||
"title": "Titel"
|
||||
"title": "Titel",
|
||||
"year": "Jahr",
|
||||
"rating": "IMDb-Wertung",
|
||||
"duration": "Länge",
|
||||
"release": "Erscheinungsdatum"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Bibliothek",
|
||||
|
|
@ -16,7 +20,20 @@
|
|||
"in_progress": "Läuft",
|
||||
"watched": "Gesehen"
|
||||
},
|
||||
"sort": "Sortierung"
|
||||
"sort": "Sortierung",
|
||||
"clearAll": "Filter zurücksetzen",
|
||||
"active": "Aktiv",
|
||||
"any": "Alle",
|
||||
"rating": "IMDb-Wertung",
|
||||
"genre": "Genre",
|
||||
"year": "Jahr",
|
||||
"duration": "Länge",
|
||||
"durationOpt": { "short": "Unter 90 Min.", "mid": "90–120 Min.", "long": "Über 2 Std." },
|
||||
"added": "Zu Plex hinzugefügt",
|
||||
"addedOpt": { "24h": "24 Std.", "1w": "1 Woche", "1m": "1 Monat", "6m": "6 Monate", "1y": "1 Jahr" },
|
||||
"contentRating": "Altersfreigabe",
|
||||
"match": { "any": "Beliebig", "all": "Alle" },
|
||||
"dir": { "desc": "↓ Absteigend", "asc": "↑ Aufsteigend" }
|
||||
},
|
||||
"count": "{{count}} Titel",
|
||||
"searchCount": "{{count}} Treffer",
|
||||
|
|
@ -53,6 +70,7 @@
|
|||
"errGeneric": "Die Wiedergabe konnte nicht gestartet werden. Bitte erneut versuchen.",
|
||||
"stop": "Stopp",
|
||||
"help": "Tastenkürzel",
|
||||
"infoBtn": "Medieninfo",
|
||||
"keys": {
|
||||
"playPause": "Wiedergabe / Pause",
|
||||
"seek": "10 Sekunden spulen",
|
||||
|
|
@ -60,10 +78,26 @@
|
|||
"volume": "Lautstärke lauter / leiser",
|
||||
"mute": "Stumm",
|
||||
"fullscreen": "Vollbild",
|
||||
"info": "Medieninfo",
|
||||
"back": "Stopp & zurück zum Feed",
|
||||
"help": "Diese Hilfe anzeigen"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "Medieninfo",
|
||||
"customize": "Ansicht anpassen",
|
||||
"prefArtBg": "Dezenter Hintergrund",
|
||||
"prefCast": "Besetzung",
|
||||
"openImdb": "Bei IMDb öffnen",
|
||||
"director": "Regie",
|
||||
"cast": "Besetzung & Crew",
|
||||
"markWatched": "Als gesehen markieren",
|
||||
"markUnwatched": "Gesehen zurücknehmen",
|
||||
"clearResume": "Fortsetzungsposition löschen",
|
||||
"filterYear": "Titel aus {{year}}",
|
||||
"filterRating": "Titel mit {{n}}+",
|
||||
"filterActor": "Titel mit {{name}}"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Spielt direkt im Browser",
|
||||
"remux": "Braucht ein leichtes Remux (keine Video-Neucodierung)",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"searchPlaceholder": "Search Plex…",
|
||||
"sort": {
|
||||
"added": "Recently added",
|
||||
"title": "Title"
|
||||
"title": "Title",
|
||||
"year": "Year",
|
||||
"rating": "IMDb rating",
|
||||
"duration": "Length",
|
||||
"release": "Release date"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Library",
|
||||
|
|
@ -16,7 +20,20 @@
|
|||
"in_progress": "In progress",
|
||||
"watched": "Watched"
|
||||
},
|
||||
"sort": "Sort"
|
||||
"sort": "Sort",
|
||||
"clearAll": "Clear filters",
|
||||
"active": "Active",
|
||||
"any": "Any",
|
||||
"rating": "IMDb rating",
|
||||
"genre": "Genre",
|
||||
"year": "Year",
|
||||
"duration": "Length",
|
||||
"durationOpt": { "short": "Under 90m", "mid": "90–120m", "long": "Over 2h" },
|
||||
"added": "Added to Plex",
|
||||
"addedOpt": { "24h": "24h", "1w": "1 week", "1m": "1 month", "6m": "6 months", "1y": "1 year" },
|
||||
"contentRating": "Age rating",
|
||||
"match": { "any": "Any", "all": "All" },
|
||||
"dir": { "desc": "↓ Descending", "asc": "↑ Ascending" }
|
||||
},
|
||||
"count": "{{count}} titles",
|
||||
"searchCount": "{{count}} matches",
|
||||
|
|
@ -53,6 +70,7 @@
|
|||
"errGeneric": "Playback couldn't start. Please try again.",
|
||||
"stop": "Stop",
|
||||
"help": "Keyboard shortcuts",
|
||||
"infoBtn": "Media info",
|
||||
"keys": {
|
||||
"playPause": "Play / pause",
|
||||
"seek": "Seek 10 seconds",
|
||||
|
|
@ -60,10 +78,26 @@
|
|||
"volume": "Volume up / down",
|
||||
"mute": "Mute",
|
||||
"fullscreen": "Fullscreen",
|
||||
"info": "Media info",
|
||||
"back": "Stop & back to feed",
|
||||
"help": "Show this help"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "Media info",
|
||||
"customize": "Customize view",
|
||||
"prefArtBg": "Faint backdrop",
|
||||
"prefCast": "Cast & crew",
|
||||
"openImdb": "Open on IMDb",
|
||||
"director": "Director",
|
||||
"cast": "Cast & crew",
|
||||
"markWatched": "Mark watched",
|
||||
"markUnwatched": "Mark unwatched",
|
||||
"clearResume": "Clear resume position",
|
||||
"filterYear": "Show titles from {{year}}",
|
||||
"filterRating": "Show titles rated {{n}}+",
|
||||
"filterActor": "Show titles with {{name}}"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Plays directly in the browser",
|
||||
"remux": "Needs a light remux (no video re-encode)",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@
|
|||
"searchPlaceholder": "Keresés a Plexben…",
|
||||
"sort": {
|
||||
"added": "Nemrég hozzáadott",
|
||||
"title": "Cím"
|
||||
"title": "Cím",
|
||||
"year": "Év",
|
||||
"rating": "IMDb pont",
|
||||
"duration": "Hossz",
|
||||
"release": "Megjelenés dátuma"
|
||||
},
|
||||
"filter": {
|
||||
"library": "Könyvtár",
|
||||
|
|
@ -16,7 +20,20 @@
|
|||
"in_progress": "Folyamatban",
|
||||
"watched": "Megnézett"
|
||||
},
|
||||
"sort": "Rendezés"
|
||||
"sort": "Rendezés",
|
||||
"clearAll": "Szűrők törlése",
|
||||
"active": "Aktív",
|
||||
"any": "Bármi",
|
||||
"rating": "IMDb pont",
|
||||
"genre": "Műfaj",
|
||||
"year": "Év",
|
||||
"duration": "Hossz",
|
||||
"durationOpt": { "short": "90 perc alatt", "mid": "90–120 perc", "long": "2 óra felett" },
|
||||
"added": "Plexhez adva",
|
||||
"addedOpt": { "24h": "24 óra", "1w": "1 hét", "1m": "1 hónap", "6m": "6 hónap", "1y": "1 év" },
|
||||
"contentRating": "Korhatár",
|
||||
"match": { "any": "Bármelyik", "all": "Mind" },
|
||||
"dir": { "desc": "↓ Csökkenő", "asc": "↑ Növekvő" }
|
||||
},
|
||||
"count": "{{count}} cím",
|
||||
"searchCount": "{{count}} találat",
|
||||
|
|
@ -53,6 +70,7 @@
|
|||
"errGeneric": "A lejátszást nem sikerült elindítani. Próbáld újra.",
|
||||
"stop": "Leállítás",
|
||||
"help": "Billentyűparancsok",
|
||||
"infoBtn": "Média infó",
|
||||
"keys": {
|
||||
"playPause": "Lejátszás / szünet",
|
||||
"seek": "Tekerés 10 másodperc",
|
||||
|
|
@ -60,10 +78,26 @@
|
|||
"volume": "Hangerő fel / le",
|
||||
"mute": "Némítás",
|
||||
"fullscreen": "Teljes képernyő",
|
||||
"info": "Média infó",
|
||||
"back": "Leállítás és vissza a feedre",
|
||||
"help": "Súgó megjelenítése"
|
||||
}
|
||||
},
|
||||
"info": {
|
||||
"title": "Média infó",
|
||||
"customize": "Nézet testreszabása",
|
||||
"prefArtBg": "Halvány háttér",
|
||||
"prefCast": "Szereplők",
|
||||
"openImdb": "Megnyitás az IMDb-n",
|
||||
"director": "Rendező",
|
||||
"cast": "Szereplők & stáb",
|
||||
"markWatched": "Megjelölés megnézettként",
|
||||
"markUnwatched": "Megnézett visszavonása",
|
||||
"clearResume": "Folytatási pozíció törlése",
|
||||
"filterYear": "{{year}} évi címek",
|
||||
"filterRating": "{{n}}+ pontos címek",
|
||||
"filterActor": "Címek {{name}} szereplésével"
|
||||
},
|
||||
"playable": {
|
||||
"direct": "Közvetlenül játszható a böngészőben",
|
||||
"remux": "Könnyű remux kell (nincs videó-újrakódolás)",
|
||||
|
|
|
|||
|
|
@ -695,7 +695,15 @@ export interface PlexItemDetail {
|
|||
playable: string; // direct | remux | transcode
|
||||
thumb: string;
|
||||
art: string;
|
||||
cast: string[];
|
||||
cast: PlexCastMember[];
|
||||
imdb_rating?: number | null;
|
||||
imdb_id?: string | null;
|
||||
imdb_url?: string | null;
|
||||
content_rating?: string | null;
|
||||
genres: string[];
|
||||
directors: string[];
|
||||
studio?: string | null;
|
||||
tagline?: string | null;
|
||||
markers: PlexMarker[];
|
||||
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
|
||||
subtitle_streams: { ord: number; label: string; language?: string | null }[];
|
||||
|
|
@ -707,6 +715,56 @@ export interface PlexItemDetail {
|
|||
prev_id?: string | null;
|
||||
next_id?: string | null;
|
||||
}
|
||||
export interface PlexCastMember {
|
||||
name: string;
|
||||
role?: string | null;
|
||||
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
|
||||
}
|
||||
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
|
||||
export interface PlexFilters {
|
||||
genres: string[];
|
||||
genreMode?: "any" | "all"; // how multiple genres combine (default any)
|
||||
contentRatings: string[];
|
||||
yearMin?: number | null;
|
||||
yearMax?: number | null;
|
||||
ratingMin?: number | null;
|
||||
durationMin?: number | null; // seconds
|
||||
durationMax?: number | null;
|
||||
addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y"
|
||||
directors: string[]; // AND — titles featuring ALL selected
|
||||
actors: string[]; // AND — titles featuring ALL selected
|
||||
studios: string[]; // OR — from any selected studio
|
||||
sortDir?: "asc" | "desc"; // sort direction (default desc)
|
||||
}
|
||||
export const EMPTY_PLEX_FILTERS: PlexFilters = {
|
||||
genres: [],
|
||||
contentRatings: [],
|
||||
directors: [],
|
||||
actors: [],
|
||||
studios: [],
|
||||
};
|
||||
export function plexFilterCount(f: PlexFilters): number {
|
||||
return (
|
||||
f.genres.length +
|
||||
f.contentRatings.length +
|
||||
(f.yearMin != null || f.yearMax != null ? 1 : 0) +
|
||||
(f.ratingMin != null ? 1 : 0) +
|
||||
(f.durationMin != null || f.durationMax != null ? 1 : 0) +
|
||||
(f.addedWithin ? 1 : 0) +
|
||||
f.directors.length +
|
||||
f.actors.length +
|
||||
f.studios.length
|
||||
);
|
||||
}
|
||||
export interface PlexFacets {
|
||||
genres: { value: string; count: number }[];
|
||||
content_ratings: { value: string; count: number }[];
|
||||
year_min: number | null;
|
||||
year_max: number | null;
|
||||
rating_max: number | null;
|
||||
duration_min: number | null;
|
||||
duration_max: number | null;
|
||||
}
|
||||
export interface PlexPlaySession {
|
||||
mode: "direct" | "hls";
|
||||
url: string;
|
||||
|
|
@ -1036,6 +1094,7 @@ export const api = {
|
|||
show?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
filters?: PlexFilters;
|
||||
}): Promise<PlexBrowseResult> => {
|
||||
const u = new URLSearchParams({ library: p.library });
|
||||
if (p.q) u.set("q", p.q);
|
||||
|
|
@ -1043,8 +1102,26 @@ export const api = {
|
|||
if (p.show && p.show !== "all") u.set("show", p.show);
|
||||
if (p.offset) u.set("offset", String(p.offset));
|
||||
if (p.limit) u.set("limit", String(p.limit));
|
||||
const f = p.filters;
|
||||
if (f) {
|
||||
if (f.genres?.length) u.set("genres", f.genres.join(","));
|
||||
if (f.genreMode === "all") u.set("genre_mode", "all");
|
||||
if (f.contentRatings?.length) u.set("content_ratings", f.contentRatings.join(","));
|
||||
if (f.yearMin != null) u.set("year_min", String(f.yearMin));
|
||||
if (f.yearMax != null) u.set("year_max", String(f.yearMax));
|
||||
if (f.ratingMin != null) u.set("rating_min", String(f.ratingMin));
|
||||
if (f.durationMin != null) u.set("duration_min", String(f.durationMin));
|
||||
if (f.durationMax != null) u.set("duration_max", String(f.durationMax));
|
||||
if (f.addedWithin) u.set("added_within", f.addedWithin);
|
||||
if (f.directors?.length) u.set("directors", f.directors.join(","));
|
||||
if (f.actors?.length) u.set("actors", f.actors.join(","));
|
||||
if (f.studios?.length) u.set("studios", f.studios.join(","));
|
||||
if (f.sortDir === "asc") u.set("sort_dir", "asc");
|
||||
}
|
||||
return req(`/api/plex/browse?${u.toString()}`);
|
||||
},
|
||||
plexFacets: (library: string): Promise<PlexFacets> =>
|
||||
req(`/api/plex/facets?library=${encodeURIComponent(library)}`),
|
||||
plexShow: (id: string): Promise<PlexShowDetail> =>
|
||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,31 @@ export interface ReleaseEntry {
|
|||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.26.0",
|
||||
date: "2026-07-05",
|
||||
summary: "Powerful Plex filters — by genre, IMDb rating, year, length, added date and age rating — and click any detail on the info page to filter by it. Plus much faster poster loading.",
|
||||
features: [
|
||||
"Plex library: a greatly expanded filter panel for movies — genre, IMDb rating, year range, length, when it was added to Plex, and age rating — plus new sort orders (release date, year, IMDb rating, length) with an ascending/descending toggle.",
|
||||
"Plex filters: pick several genres and choose Any (match one) or All (match every one); stack multiple actors, directors and studios at once (from the info page).",
|
||||
"Plex info page: the year, genres, director, cast and studio are now clickable — tap one to filter the whole library by it (e.g. every film with an actor, or everything rated 7+). Browser Back returns to the info page.",
|
||||
"Plex: posters and cast photos are cached on disk after first view, so scrolling the library is much smoother on repeat visits.",
|
||||
],
|
||||
fixes: [
|
||||
"Plex player: leaving a video with Back now fully stops it — no more audio playing in the background until a refresh.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.25.0",
|
||||
date: "2026-07-05",
|
||||
summary: "Rich Plex media info — an info page from any title, an info overlay while watching, IMDb scores and cast photos.",
|
||||
features: [
|
||||
"Plex: every movie/episode now has a media info page (the “i” button on a card) with the poster, IMDb score + a link to IMDb, content rating, genres, director, a full cast list with photos, and the synopsis — plus Play/Resume and watch controls.",
|
||||
"Plex player: press “I” (or the info button) for the same rich info as an overlay, without stopping playback.",
|
||||
"Plex info: two view preferences you can toggle and that stick — a faint art backdrop and the cast & crew row.",
|
||||
"Plex: you can now mark a title unwatched or clear its resume position from the info page.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.24.0",
|
||||
date: "2026-07-05",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export const LS = {
|
|||
plexLibrary: "siftlode.plexLibrary",
|
||||
plexShow: "siftlode.plexShow",
|
||||
plexSort: "siftlode.plexSort",
|
||||
plexFilters: "siftlode.plexFilters",
|
||||
notifHistory: "siftlode.notifications",
|
||||
notifSettings: "siftlode.notifSettings",
|
||||
onboardingDismissed: "siftlode.onboarding.dismissed",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue