refactor(plex): address /code-review on the R12 S1 split
Cleanup only, no behaviour change (ruff clean + pytest 193): - imports hoisted above `router =` and grouped/sorted (isort) across the split files; ruff-format pass restores the 2-blank-line PEP8 spacing the generator collapsed to 1. - the shared on-disk cache root moves to a neutral app/plex/cache.py, so subtitles.py no longer imports it from imgcache.py (drops the cross-import). - the former plex.py module docstring's access-model note (read endpoints open to any authed user; admin endpoints test/sync) is restored in the package __init__.py.
This commit is contained in:
@@ -4,11 +4,20 @@ import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user, resolved_user_id
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import (
|
||||
@@ -18,18 +27,16 @@ from app.models import (
|
||||
PlexState,
|
||||
User,
|
||||
)
|
||||
from app.plex import imgcache, subtitles
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex import people as plex_people
|
||||
from app.plex import stream as plex_stream
|
||||
from app.plex import watch_sync as plex_watch
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.plex import imgcache, subtitles
|
||||
from app.routes.plex._shared import _item_or_404
|
||||
|
||||
router = APIRouter(prefix="/api/plex", tags=["plex"])
|
||||
|
||||
from app.routes.plex._shared import (
|
||||
_item_or_404,
|
||||
)
|
||||
|
||||
def _require_session(request: Request) -> int:
|
||||
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
|
||||
@@ -40,6 +47,7 @@ def _require_session(request: Request) -> int:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return uid
|
||||
|
||||
|
||||
@router.get("/image/{rating_key}")
|
||||
def image(rating_key: str, request: Request, variant: str = "thumb") -> Response:
|
||||
"""Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A
|
||||
@@ -66,7 +74,9 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
||||
key = imgcache.key_for(db, str(rating_key), v)
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="No image")
|
||||
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
||||
plex = PlexClient(
|
||||
db
|
||||
) # reads base URL + token now; the fetch below needs no DB
|
||||
except PlexNotConfigured as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
try:
|
||||
@@ -77,8 +87,11 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
||||
imgcache.write(cache_key, data, ct)
|
||||
return imgcache.response(data, ct)
|
||||
|
||||
|
||||
@router.get("/subtitle/{rating_key}/{sub_ord}")
|
||||
def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.0) -> Response:
|
||||
def subtitle(
|
||||
rating_key: str, sub_ord: int, request: Request, offset: float = 0.0
|
||||
) -> Response:
|
||||
"""Serve the ord-th subtitle track as WebVTT for the browser's <track> — so choosing a subtitle
|
||||
overlays it directly (no HLS re-mux / session restart). Handles EXTERNAL sidecar subs (fetched
|
||||
from Plex via the stream `key` — the old `-map 0:s` broke on these since they're not in the file)
|
||||
@@ -94,14 +107,21 @@ def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.
|
||||
with SessionLocal() as db:
|
||||
it = _item_or_404(db, rating_key)
|
||||
with PlexClient(db) as plex:
|
||||
part = ((plex.metadata(it.rating_key) or {}).get("Media") or [{}])[0].get("Part") or [{}]
|
||||
subs = [s for s in (part[0].get("Stream") or []) if s.get("streamType") == 3]
|
||||
part = ((plex.metadata(it.rating_key) or {}).get("Media") or [{}])[
|
||||
0
|
||||
].get("Part") or [{}]
|
||||
subs = [
|
||||
s for s in (part[0].get("Stream") or []) if s.get("streamType") == 3
|
||||
]
|
||||
if sub_ord < 0 or sub_ord >= len(subs):
|
||||
raise HTTPException(status_code=404, detail="No such subtitle")
|
||||
s = subs[sub_ord]
|
||||
codec = (s.get("codec") or s.get("format") or "").lower()
|
||||
if codec in subtitles.BITMAP_SUBS:
|
||||
raise HTTPException(status_code=415, detail="Image-based subtitle can't be shown as text")
|
||||
raise HTTPException(
|
||||
status_code=415,
|
||||
detail="Image-based subtitle can't be shown as text",
|
||||
)
|
||||
if s.get("key"): # external sidecar → fetch from Plex, convert to VTT
|
||||
raw = plex.raw_get(s["key"])
|
||||
if codec in ("srt", "subrip", ""):
|
||||
@@ -114,14 +134,22 @@ def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.
|
||||
vtt = subtitles.ffmpeg_to_vtt(tmp, None)
|
||||
finally:
|
||||
os.unlink(tmp)
|
||||
elif s.get("index") is not None: # embedded → extract from the local file
|
||||
elif (
|
||||
s.get("index") is not None
|
||||
): # embedded → extract from the local file
|
||||
src = plex_paths.local_media_path(db, it.file_path)
|
||||
if src is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
emb_ord = sum(1 for x in subs[:sub_ord] if x.get("index") is not None)
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Media file not found on disk"
|
||||
)
|
||||
emb_ord = sum(
|
||||
1 for x in subs[:sub_ord] if x.get("index") is not None
|
||||
)
|
||||
vtt = subtitles.ffmpeg_to_vtt(str(src), emb_ord)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Subtitle not fetchable")
|
||||
raise HTTPException(
|
||||
status_code=404, detail="Subtitle not fetchable"
|
||||
)
|
||||
except PlexNotConfigured as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except PlexError as e:
|
||||
@@ -129,10 +157,12 @@ def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.
|
||||
subtitles.cache_write(cache_key, vtt)
|
||||
return Response(subtitles.shift_vtt(vtt, offset), media_type="text/vtt")
|
||||
|
||||
|
||||
_PROGRESS_MIN_S = 5
|
||||
|
||||
_FINISH_MARGIN_S = 30
|
||||
|
||||
|
||||
@router.get("/person-image")
|
||||
def person_image(u: str, request: Request) -> Response:
|
||||
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
|
||||
@@ -154,6 +184,7 @@ def person_image(u: str, request: Request) -> Response:
|
||||
imgcache.write(cache_key, r.content, ct)
|
||||
return imgcache.response(r.content, ct)
|
||||
|
||||
|
||||
def _push_watch(
|
||||
background: BackgroundTasks,
|
||||
db: Session,
|
||||
@@ -173,10 +204,17 @@ def _push_watch(
|
||||
# lands before it settles synced_to_plex (which would otherwise strand that newer value).
|
||||
st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
|
||||
background.add_task(
|
||||
plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms,
|
||||
plex_watch.push_state_to_plex,
|
||||
user_id,
|
||||
item_id,
|
||||
rating_key,
|
||||
action,
|
||||
time_ms,
|
||||
duration_ms,
|
||||
plex_watch._state_marker(st),
|
||||
)
|
||||
|
||||
|
||||
class ItemProgressIn(BaseModel):
|
||||
# float, truncated in the handler — the player may post a raw currentTime (a float); a plain
|
||||
# `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated).
|
||||
@@ -184,6 +222,7 @@ class ItemProgressIn(BaseModel):
|
||||
duration_seconds: float = 0
|
||||
final: bool = False
|
||||
|
||||
|
||||
@router.post("/item/{rating_key}/progress")
|
||||
def item_progress(
|
||||
rating_key: str,
|
||||
@@ -241,13 +280,22 @@ def item_progress(
|
||||
db.commit()
|
||||
if final:
|
||||
_push_watch(
|
||||
background, db, user.id, it.id, rating_key, "resume", position * 1000, duration * 1000
|
||||
background,
|
||||
db,
|
||||
user.id,
|
||||
it.id,
|
||||
rating_key,
|
||||
"resume",
|
||||
position * 1000,
|
||||
duration * 1000,
|
||||
)
|
||||
return {"position_seconds": position}
|
||||
|
||||
|
||||
class ItemStateIn(BaseModel):
|
||||
status: str = ""
|
||||
|
||||
|
||||
@router.post("/item/{rating_key}/state")
|
||||
def item_state(
|
||||
rating_key: str,
|
||||
@@ -285,8 +333,13 @@ def item_state(
|
||||
_push_watch(background, db, user.id, it.id, rating_key, "watched")
|
||||
return {"status": status}
|
||||
|
||||
|
||||
def _bulk_state(
|
||||
background: BackgroundTasks, db: Session, user_id: int, eps: list[PlexItem], watched: bool
|
||||
background: BackgroundTasks,
|
||||
db: Session,
|
||||
user_id: int,
|
||||
eps: list[PlexItem],
|
||||
watched: bool,
|
||||
) -> int:
|
||||
"""Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a
|
||||
linked Plex account in the background (best-effort). Returns how many rows actually changed."""
|
||||
@@ -294,7 +347,8 @@ def _bulk_state(
|
||||
existing = {
|
||||
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])
|
||||
PlexState.user_id == user_id,
|
||||
PlexState.item_id.in_([e.id for e in eps] or [0]),
|
||||
)
|
||||
}
|
||||
pushable = plex_watch.link_for_push(db, user_id) is not None
|
||||
@@ -324,9 +378,11 @@ def _bulk_state(
|
||||
background.add_task(plex_watch.push_bulk_state_to_plex, user_id, to_push)
|
||||
return len(to_push)
|
||||
|
||||
|
||||
class WatchedIn(BaseModel):
|
||||
watched: bool = False
|
||||
|
||||
|
||||
@router.post("/show/{rating_key}/state")
|
||||
def show_state(
|
||||
rating_key: str,
|
||||
@@ -344,6 +400,7 @@ def show_state(
|
||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||
return {"changed": changed, "watched": watched}
|
||||
|
||||
|
||||
@router.post("/season/{rating_key}/state")
|
||||
def season_state(
|
||||
rating_key: str,
|
||||
@@ -361,6 +418,7 @@ def season_state(
|
||||
changed = _bulk_state(background, db, user.id, eps, watched)
|
||||
return {"changed": changed, "watched": watched}
|
||||
|
||||
|
||||
@router.post("/stream/{rating_key}/session")
|
||||
def stream_session(
|
||||
rating_key: str,
|
||||
@@ -378,11 +436,17 @@ def stream_session(
|
||||
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||
it = _item_or_404(db, rating_key)
|
||||
if it.playable == "transcode":
|
||||
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
||||
raise HTTPException(
|
||||
status_code=501, detail="This format needs transcoding (a later phase)."
|
||||
)
|
||||
# `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and
|
||||
# the client can switch audio without a restart — even for otherwise direct-playable files.
|
||||
if it.playable == "direct" and audio is None and not aoff and not multi:
|
||||
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
|
||||
return {
|
||||
"mode": "direct",
|
||||
"url": f"/api/plex/stream/{it.rating_key}/file",
|
||||
"start_s": 0.0,
|
||||
}
|
||||
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
|
||||
@@ -390,7 +454,12 @@ def stream_session(
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
# Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the
|
||||
# requested offset — the client's absolute clock and the subtitle cue-shift both key off this.
|
||||
return {"mode": "hls", "start_s": s.media_start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
|
||||
return {
|
||||
"mode": "hls",
|
||||
"start_s": s.media_start_s,
|
||||
"url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/file")
|
||||
def stream_file(
|
||||
@@ -409,11 +478,22 @@ def stream_file(
|
||||
if download:
|
||||
# A clean, safe download name from the title + the real file extension.
|
||||
ext = src.suffix or (f".{it.container}" if it.container else "")
|
||||
base = "".join(c for c in (it.title or "video") if c.isalnum() or c in " -_.").strip() or "video"
|
||||
base = (
|
||||
"".join(
|
||||
c for c in (it.title or "video") if c.isalnum() or c in " -_."
|
||||
).strip()
|
||||
or "video"
|
||||
)
|
||||
return FileResponse(str(src), filename=f"{base}{ext}")
|
||||
return FileResponse(str(src)) # Starlette honours the Range header (206)
|
||||
|
||||
_HLS_CT = {".m3u8": "application/vnd.apple.mpegurl", ".ts": "video/mp2t", ".vtt": "text/vtt"}
|
||||
|
||||
_HLS_CT = {
|
||||
".m3u8": "application/vnd.apple.mpegurl",
|
||||
".ts": "video/mp2t",
|
||||
".vtt": "text/vtt",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/hls/{filename}")
|
||||
def stream_hls_file(
|
||||
@@ -446,5 +526,7 @@ def stream_hls_file(
|
||||
if not plex_stream.wait_for(path, timeout):
|
||||
raise HTTPException(status_code=404, detail="Not available yet")
|
||||
return Response(
|
||||
content=path.read_bytes(), media_type=_HLS_CT[ext], headers={"Cache-Control": "no-store"}
|
||||
content=path.read_bytes(),
|
||||
media_type=_HLS_CT[ext],
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user