feat(plex): P1 backend — catalog sync + browse/search/show/image API
- app/plex/sync.py: full reconcile of enabled movie/show sections into plex_*
(upsert by rating_key). Reuses Plex's own codec info to classify browser
playability (direct/remux/transcode) — no ffprobe. Movies + episodes become
playable leaves; shows/seasons mirrored for the drill-down.
- routes/plex.py: GET /libraries, /browse (FTS search + sort + paging, movie
leaves or show cards), /show/{rk} (seasons+episodes, per-user state), /image
(proxies Plex art, no duplication); admin POST /sync. All auth'd, demo-allowed.
- scheduler: plex_sync job (interval settings.plex_sync_interval_min).
- Verified against the real server: 2 libs, 3206 movies, 260 shows/961 seasons/
14768 episodes synced in 13s; FTS search + drill-down work. Playability:
~1% direct, 89% remux, 6% transcode (informs P2/P3).
This commit is contained in:
parent
9afb1a9788
commit
63c6e782c8
4 changed files with 483 additions and 16 deletions
|
|
@ -1,27 +1,39 @@
|
|||
"""Plex integration API.
|
||||
|
||||
Read endpoints (browse/search/stream/image/state) are available to ANY authenticated user — the
|
||||
whole point of the module is to be an access layer to the Plex library WITHOUT requiring a plex.tv
|
||||
account, and playback is a local file (no quota/credit cost), so demo + pure-Siftlode users are
|
||||
allowed. Admin endpoints configure + test the connection and trigger a sync.
|
||||
|
||||
P0 ships only the admin connectivity endpoints; browse/search/player land in P1/P2.
|
||||
Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo +
|
||||
pure-Siftlode included) — the module is an access layer to the Plex library WITHOUT requiring a
|
||||
plex.tv account, and playback is a local file. Admin endpoints test the connection and run the
|
||||
catalog sync.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import User
|
||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.routes.admin import admin_user
|
||||
from app.routes.feed import _to_tsquery_str
|
||||
|
||||
log = logging.getLogger("siftlode.plex")
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
_TS_CONFIG = "public.unaccent_simple"
|
||||
|
||||
|
||||
# --- Admin: connectivity + sync ---------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""Admin: verify the configured Plex server URL + token, returning the server name and the
|
||||
available library sections (movie/show) for the config library-picker."""
|
||||
movie/show sections (for the config library-picker)."""
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
info = plex.server_info()
|
||||
|
|
@ -35,13 +47,223 @@ def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)
|
|||
"server_name": info.get("friendlyName") or info.get("title1") or "Plex",
|
||||
"version": info.get("version"),
|
||||
"sections": [
|
||||
{
|
||||
"key": str(s.get("key")),
|
||||
"title": s.get("title"),
|
||||
"type": s.get("type"),
|
||||
"count": s.get("count"),
|
||||
}
|
||||
{"key": str(s.get("key")), "title": s.get("title"), "type": s.get("type"), "count": s.get("count")}
|
||||
for s in sections
|
||||
if s.get("type") in ("movie", "show")
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a
|
||||
while on a large library); a background/scheduled sync also runs periodically."""
|
||||
return plex_sync.sync(db)
|
||||
|
||||
|
||||
# --- Read: libraries / browse / show / image --------------------------------------------------
|
||||
|
||||
|
||||
def _enabled() -> None:
|
||||
"""Guard read endpoints when the module is off (avoids leaking a stale mirror)."""
|
||||
# Read endpoints stay usable as long as a mirror exists; the toggle only gates sync + UI.
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/libraries")
|
||||
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
"""The mirrored libraries the user can browse (drives the Plex scope selector)."""
|
||||
libs = db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all()
|
||||
out = []
|
||||
for lib in libs:
|
||||
if lib.kind == "movie":
|
||||
count = db.query(func.count(PlexItem.id)).filter_by(library_id=lib.id, kind="movie").scalar()
|
||||
else:
|
||||
count = db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar()
|
||||
out.append({"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count or 0})
|
||||
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
|
||||
|
||||
|
||||
def _movie_card(it: PlexItem, st: PlexState | None) -> dict:
|
||||
return {
|
||||
"id": it.rating_key,
|
||||
"type": "movie",
|
||||
"title": it.title,
|
||||
"year": it.year,
|
||||
"duration_seconds": it.duration_s,
|
||||
"thumb": f"/api/plex/image/{it.rating_key}",
|
||||
"playable": it.playable,
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
}
|
||||
|
||||
|
||||
def _show_card(sh: PlexShow) -> dict:
|
||||
return {
|
||||
"id": sh.rating_key,
|
||||
"type": "show",
|
||||
"title": sh.title,
|
||||
"year": sh.year,
|
||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||
"season_count": sh.child_count,
|
||||
}
|
||||
|
||||
|
||||
def _episode_card(e: PlexItem, st: PlexState | None) -> dict:
|
||||
return {
|
||||
"id": e.rating_key,
|
||||
"type": "episode",
|
||||
"title": e.title,
|
||||
"summary": e.summary,
|
||||
"season_number": e.season_number,
|
||||
"episode_number": e.episode_number,
|
||||
"duration_seconds": e.duration_s,
|
||||
"thumb": f"/api/plex/image/{e.rating_key}",
|
||||
"playable": e.playable,
|
||||
"status": st.status if st else "new",
|
||||
"position_seconds": st.position_seconds if st else 0,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/browse")
|
||||
def browse(
|
||||
library: str,
|
||||
q: str | None = None,
|
||||
sort: str = "added",
|
||||
offset: int = 0,
|
||||
limit: int = Query(default=40, ge=1, le=100),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""List a library's movies (leaves) or shows, with optional FTS search + sorting.
|
||||
Movie libraries return playable movie cards; show libraries return show cards (drill in via
|
||||
/show/{id})."""
|
||||
lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first()
|
||||
if lib is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex library")
|
||||
offset = max(0, offset)
|
||||
model = PlexItem if lib.kind == "movie" else PlexShow
|
||||
|
||||
query = db.query(model)
|
||||
if lib.kind == "movie":
|
||||
query = query.filter(PlexItem.library_id == lib.id, PlexItem.kind == "movie")
|
||||
else:
|
||||
query = query.filter(PlexShow.library_id == lib.id)
|
||||
|
||||
ts = _to_tsquery_str(q) if q else None
|
||||
tsq = None
|
||||
if ts:
|
||||
tsq = func.to_tsquery(_TS_CONFIG, ts)
|
||||
query = query.filter(model.search_vector.op("@@")(tsq))
|
||||
|
||||
total = query.count()
|
||||
|
||||
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()]
|
||||
order.append(model.id.desc())
|
||||
|
||||
rows = query.order_by(*order).offset(offset).limit(limit).all()
|
||||
|
||||
if lib.kind == "movie":
|
||||
by_item = {r.id: r for r in rows}
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.query(PlexState).filter(
|
||||
PlexState.user_id == user.id, PlexState.item_id.in_(list(by_item.keys()) or [0])
|
||||
)
|
||||
}
|
||||
items = [_movie_card(r, states.get(r.id)) for r in rows]
|
||||
else:
|
||||
items = [_show_card(r) for r in rows]
|
||||
|
||||
return {"kind": lib.kind, "total": total, "offset": offset, "limit": limit, "items": items}
|
||||
|
||||
|
||||
@router.get("/show/{rating_key}")
|
||||
def show_detail(
|
||||
rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""A show's seasons + episodes (the drill-down view) with per-user watch state."""
|
||||
sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first()
|
||||
if sh is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex show")
|
||||
seasons = (
|
||||
db.query(PlexSeason).filter_by(show_id=sh.id).order_by(PlexSeason.season_number.asc().nullsfirst()).all()
|
||||
)
|
||||
eps = (
|
||||
db.query(PlexItem)
|
||||
.filter_by(show_id=sh.id, kind="episode")
|
||||
.order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst())
|
||||
.all()
|
||||
)
|
||||
states = {
|
||||
s.item_id: s
|
||||
for s in db.query(PlexState).filter(
|
||||
PlexState.user_id == user.id, PlexState.item_id.in_([e.id for e in eps] or [0])
|
||||
)
|
||||
}
|
||||
by_season: dict[int | None, list] = {}
|
||||
for e in eps:
|
||||
by_season.setdefault(e.season_id, []).append(_episode_card(e, states.get(e.id)))
|
||||
return {
|
||||
"show": {
|
||||
"id": sh.rating_key,
|
||||
"title": sh.title,
|
||||
"summary": sh.summary,
|
||||
"year": sh.year,
|
||||
"thumb": f"/api/plex/image/{sh.rating_key}",
|
||||
"art": f"/api/plex/image/{sh.rating_key}?variant=art",
|
||||
},
|
||||
"seasons": [
|
||||
{
|
||||
"id": se.rating_key,
|
||||
"season_number": se.season_number,
|
||||
"title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"),
|
||||
"thumb": f"/api/plex/image/{se.rating_key}",
|
||||
"episodes": by_season.get(se.id, []),
|
||||
}
|
||||
for se in seasons
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
|
||||
"""The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys
|
||||
are proxyable — this is not an open image proxy."""
|
||||
it = db.query(PlexItem).filter_by(rating_key=rating_key).first()
|
||||
if it is not None:
|
||||
return it.art_key if variant == "art" else it.thumb_key
|
||||
sh = db.query(PlexShow).filter_by(rating_key=rating_key).first()
|
||||
if sh is not None:
|
||||
return sh.art_key if variant == "art" else sh.thumb_key
|
||||
se = db.query(PlexSeason).filter_by(rating_key=rating_key).first()
|
||||
if se is not None:
|
||||
return se.thumb_key
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/image/{rating_key}")
|
||||
def image(
|
||||
rating_key: str,
|
||||
variant: str = "thumb",
|
||||
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")
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="No image")
|
||||
try:
|
||||
with PlexClient(db) as plex:
|
||||
data, ct = plex.image_bytes(key)
|
||||
except PlexNotConfigured as e:
|
||||
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"})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue