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