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:
npeter83 2026-07-05 23:45:55 +02:00
parent 690e17611c
commit eefd7e3abd
15 changed files with 842 additions and 122 deletions

View file

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

View file

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

View file

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