feat(plex): expanded metadata filters, clickable info page, image disk cache
Backend (migration 0045_plex_filter_meta): plex_items gains rating (audienceRating ~IMDb), content_rating, studio, originally_available_at, and GIN-indexed genres / directors / cast_names — all mirrored from the cheap section listing (no per-item API calls; they also seed a future watch-habit recommender). /browse gains genre / content- rating / year / rating / duration / added-within / director / actor / studio filters (@> containment, GIN) + sort by year|rating|duration|release; new /facets endpoint returns available genres+ratings (with counts) and the year/rating/duration bounds. A thin on-disk image cache (.plex-img-cache) serves posters/art/cast photos from local disk after first fetch (~7-14x faster repeat loads). Frontend: PlexSidebar grows the full filter set (facet-driven genre/age chips, rating steps, year range inputs, duration buckets, added-within, active people/studio chips, clear-all); filters persist per-account as one JSON blob. PlexInfo metadata (year, genre, director, cast, studio, IMDb score) is clickable → sets the matching filter and returns to the filtered grid (page variant only; the in-player overlay stays read-only so a stray click can't stop playback). i18n en/hu/de.
This commit is contained in:
parent
690e17611c
commit
eefd7e3abd
15 changed files with 842 additions and 122 deletions
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,18 +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
|
||||
|
|
@ -169,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,
|
||||
|
|
@ -177,6 +192,17 @@ def browse(
|
|||
show: str = "all",
|
||||
offset: int = 0,
|
||||
limit: int = Query(default=40, ge=1, le=100),
|
||||
genres: str | None = None,
|
||||
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,
|
||||
director: str | None = None,
|
||||
actor: str | None = None,
|
||||
studio: str | None = None,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
|
|
@ -203,6 +229,32 @@ 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) ---
|
||||
genre_any = _csv(genres)
|
||||
if genre_any:
|
||||
query = query.filter(or_(*[PlexItem.genres.contains([g]) for g in genre_any]))
|
||||
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)
|
||||
if director:
|
||||
query = query.filter(PlexItem.directors.contains([director]))
|
||||
if actor:
|
||||
query = query.filter(PlexItem.cast_names.contains([actor]))
|
||||
if studio:
|
||||
query = query.filter(PlexItem.studio == studio)
|
||||
else:
|
||||
query = query.filter(PlexShow.library_id == lib.id)
|
||||
|
||||
|
|
@ -218,7 +270,15 @@ def browse(
|
|||
order = [func.ts_rank(model.search_vector, tsq).desc()]
|
||||
elif sort == "title":
|
||||
order = [func.lower(model.title).asc()]
|
||||
else: # newest first
|
||||
elif sort == "year" and model is PlexItem:
|
||||
order = [PlexItem.year.desc().nullslast()]
|
||||
elif sort == "rating" and model is PlexItem:
|
||||
order = [PlexItem.rating.desc().nullslast()]
|
||||
elif sort == "duration" and model is PlexItem:
|
||||
order = [PlexItem.duration_s.desc().nullslast()]
|
||||
elif sort == "release" and model is PlexItem:
|
||||
order = [PlexItem.originally_available_at.desc().nullslast()]
|
||||
else: # added — newest first
|
||||
order = [model.added_at.desc().nullslast()]
|
||||
order.append(model.id.desc())
|
||||
|
||||
|
|
@ -239,6 +299,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,
|
||||
|
|
@ -304,6 +411,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,
|
||||
|
|
@ -311,8 +454,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:
|
||||
|
|
@ -322,7 +471,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) ----------------------------
|
||||
|
|
@ -404,17 +554,19 @@ 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")
|
||||
return Response(
|
||||
content=r.content,
|
||||
media_type=r.headers.get("content-type", "image/jpeg"),
|
||||
headers={"Cache-Control": "public, max-age=604800"},
|
||||
)
|
||||
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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue