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:
2026-08-11 21:46:43 +02:00
parent 99befa6e38
commit f484de1008
9 changed files with 640 additions and 156 deletions
+13
View File
@@ -0,0 +1,13 @@
"""Shared on-disk cache root for proxied Plex assets.
Both poster/art images (imgcache.py) and converted subtitle VTTs (subtitles.py) are cached here, so
the directory belongs to neither module — it lives in this neutral home to avoid a cross-import.
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).
"""
from pathlib import Path
from app.config import settings
CACHE_DIR = Path(settings.download_root) / ".plex-img-cache"
+5 -6
View File
@@ -5,18 +5,15 @@ LAN (janky). We cache the fetched bytes on local disk keyed by a stable id, so r
other users) serve instantly from disk. On-demand + bounded by the library size; no eviction (a 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). few hundred MB at most for a big library — acceptable, matching the download disk trade-offs).
`CACHE_DIR` is the shared Plex on-disk cache root; converted subtitle VTTs live here too The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see ``subtitles.py``).
(see ``subtitles.py``).
""" """
from pathlib import Path
from fastapi import Response from fastapi import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
from app.plex.cache import CACHE_DIR
CACHE_DIR = Path(settings.download_root) / ".plex-img-cache"
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")] _EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
_CT_EXT = {ct: ext for ext, ct in _EXT_CT} _CT_EXT = {ct: ext for ext, ct in _EXT_CT}
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/ # Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/
@@ -65,4 +62,6 @@ def write(key: str, data: bytes, ct: str) -> None:
def response(data: bytes, ct: str) -> Response: def response(data: bytes, ct: str) -> Response:
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}) return Response(
content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}
)
+10 -2
View File
@@ -2,6 +2,7 @@
Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons. Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons.
""" """
from sqlalchemy import and_, case, func, or_ from sqlalchemy import and_, case, func, or_
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
@@ -23,7 +24,11 @@ def rollup(cards: list[dict]) -> dict:
the episode count. Drives the show/season Resume + Play + mark-all buttons.""" the episode count. Drives the show/season Resume + Play + mark-all buttons."""
total = len(cards) total = len(cards)
watched = sum(1 for c in cards if c.get("status") == "watched") watched = sum(1 for c in cards if c.get("status") == "watched")
inprog = [c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"] inprog = [
c
for c in cards
if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"
]
resume = None resume = None
if inprog: if inprog:
resume = inprog[-1] # continue the latest-started episode resume = inprog[-1] # continue the latest-started episode
@@ -49,7 +54,10 @@ def agg_subquery(db: Session, user_id: int):
func.count( func.count(
case( case(
( (
and_(ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched")), and_(
ps.position_seconds > 0,
or_(ps.status.is_(None), ps.status != "watched"),
),
1, 1,
) )
) )
+35 -8
View File
@@ -2,22 +2,33 @@
Text subtitles (embedded or external) are turned into WebVTT so the browser <track> can render them; Text subtitles (embedded or external) are turned into WebVTT so the browser <track> can render them;
bitmap subtitles (Blu-ray/DVD) can't be (would need burn-in). Converted VTTs are cached on the shared bitmap subtitles (Blu-ray/DVD) can't be (would need burn-in). Converted VTTs are cached on the shared
Plex on-disk cache root (``imgcache.CACHE_DIR``). Plex on-disk cache root (``cache.CACHE_DIR``).
""" """
import re import re
import subprocess import subprocess
from app.plex.cache import CACHE_DIR
from app.plex.client import PlexError from app.plex.client import PlexError
from app.plex.imgcache import CACHE_DIR
# Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in). # Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in).
BITMAP_SUBS = {"pgs", "hdmv_pgs_subtitle", "dvd_subtitle", "dvdsub", "vobsub", "dvbsub", "dvb_subtitle"} BITMAP_SUBS = {
"pgs",
"hdmv_pgs_subtitle",
"dvd_subtitle",
"dvdsub",
"vobsub",
"dvbsub",
"dvb_subtitle",
}
def srt_to_vtt(text: str) -> str: def srt_to_vtt(text: str) -> str:
"""Minimal SubRip→WebVTT: add the header and switch the ms separator (`,`→`.`). SRT cue-index """Minimal SubRip→WebVTT: add the header and switch the ms separator (`,`→`.`). SRT cue-index
lines are valid in WebVTT, so they're left as-is.""" lines are valid in WebVTT, so they're left as-is."""
body = re.sub(r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip("")) body = re.sub(
r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip("")
)
return "WEBVTT\n\n" + body.strip() + "\n" return "WEBVTT\n\n" + body.strip() + "\n"
@@ -75,7 +86,9 @@ def shift_vtt(vtt: str, offset: float) -> str:
if en <= 0: if en <= 0:
keep = False # whole cue is before the session start → drop the block keep = False # whole cue is before the session start → drop the block
else: else:
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0 lines[i] = (
f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0
)
break break
if keep: if keep:
out_blocks.append("\n".join(lines)) out_blocks.append("\n".join(lines))
@@ -85,11 +98,25 @@ def shift_vtt(vtt: str, offset: float) -> str:
def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str: def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
"""Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT """Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT
external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams.""" external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams."""
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-i", src, cmd = [
"-map", f"0:s:{sub_ord if sub_ord is not None else 0}", "-f", "webvtt", "pipe:1"] "ffmpeg",
"-nostdin",
"-loglevel",
"error",
"-i",
src,
"-map",
f"0:s:{sub_ord if sub_ord is not None else 0}",
"-f",
"webvtt",
"pipe:1",
]
r = subprocess.run(cmd, capture_output=True) r = subprocess.run(cmd, capture_output=True)
if r.returncode != 0 or not r.stdout: if r.returncode != 0 or not r.stdout:
raise PlexError((r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed") raise PlexError(
(r.stderr or b"").decode("utf-8", "replace")[:200]
or "ffmpeg subtitle convert failed"
)
return r.stdout.decode("utf-8", "replace") return r.stdout.decode("utf-8", "replace")
+5
View File
@@ -1,8 +1,13 @@
"""Plex route package — assembles the admin/browse/playback sub-routers. """Plex route package — assembles the admin/browse/playback sub-routers.
Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo +
pure-Siftlode included) — this 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.
Split out of the former single routes/plex.py (R12 S1). `router` keeps the same shape the app Split out of the former single routes/plex.py (R12 S1). `router` keeps the same shape the app
wiring expects (`plex_routes.router`), so main.py is unchanged. wiring expects (`plex_routes.router`), so main.py is unchanged.
""" """
from fastapi import APIRouter from fastapi import APIRouter
from app.routes.plex import admin, browse, playback from app.routes.plex import admin, browse, playback
+3
View File
@@ -1,14 +1,17 @@
"""Shared helpers, serializers and constants for the plex route package.""" """Shared helpers, serializers and constants for the plex route package."""
import logging import logging
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import ( from app.models import (
PlexItem, PlexItem,
) )
log = logging.getLogger("siftlode.plex") log = logging.getLogger("siftlode.plex")
def _item_or_404(db: Session, rating_key: str) -> PlexItem: def _item_or_404(db: Session, rating_key: str) -> PlexItem:
it = db.query(PlexItem).filter_by(rating_key=str(rating_key)).first() it = db.query(PlexItem).filter_by(rating_key=str(rating_key)).first()
if it is None: if it is None:
+26 -4
View File
@@ -1,9 +1,11 @@
"""Plex admin: connection test, catalog sync, and owner watch-link config.""" """Plex admin: connection test, catalog sync, and owner watch-link config."""
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import sysconfig from app import sysconfig
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
@@ -21,8 +23,11 @@ from app.utils import iso
router = APIRouter(prefix="/api/plex", tags=["plex"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
@router.post("/test") @router.post("/test")
def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: 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 """Admin: verify the configured Plex server URL + token, returning the server name and the
movie/show sections (for the config library-picker). Also probes ONE real media file through movie/show sections (for the config library-picker). Also probes ONE real media file through
the local mount, so a missing bind-mount / wrong path-map is caught here rather than only the local mount, so a missing bind-mount / wrong path-map is caught here rather than only
@@ -41,14 +46,22 @@ def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)
"server_name": info.get("friendlyName") or info.get("title1") or "Plex", "server_name": info.get("friendlyName") or info.get("title1") or "Plex",
"version": info.get("version"), "version": info.get("version"),
"sections": [ "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 for s in sections
if s.get("type") in ("movie", "show") if s.get("type") in ("movie", "show")
], ],
"media_check": media_check, "media_check": media_check,
} }
def _sample_plex_path(db: Session, plex: PlexClient, sections: list[dict]) -> str | None:
def _sample_plex_path(
db: Session, plex: PlexClient, sections: list[dict]
) -> str | None:
"""A real Plex-side media file path to validate the mount + path-map against. Prefers the """A real Plex-side media file path to validate the mount + path-map against. Prefers the
already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled
section straight from Plex.""" section straight from Plex."""
@@ -69,6 +82,7 @@ def _sample_plex_path(db: Session, plex: PlexClient, sections: list[dict]) -> st
return fp return fp
return None return None
def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict: def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict:
"""Resolve a sample Plex file path to its local mount path and confirm it's readable, so the """Resolve a sample Plex file path to its local mount path and confirm it's readable, so the
admin sees at Test time whether the physical media is actually reachable (bind-mount + admin sees at Test time whether the physical media is actually reachable (bind-mount +
@@ -80,12 +94,14 @@ def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict:
ok = plex_paths.local_media_path(db, plex_path) is not None ok = plex_paths.local_media_path(db, plex_path) is not None
return {"checked": True, "ok": ok, "plex_path": plex_path, "local_path": local_path} return {"checked": True, "ok": ok, "plex_path": plex_path, "local_path": local_path}
@router.post("/sync") @router.post("/sync")
def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: 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 """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.""" while on a large library); a background/scheduled sync also runs periodically."""
return plex_sync.sync(db) return plex_sync.sync(db)
def _link_dict(link: PlexLink | None) -> dict: def _link_dict(link: PlexLink | None) -> dict:
return { return {
"linked": link is not None, "linked": link is not None,
@@ -95,6 +111,7 @@ def _link_dict(link: PlexLink | None) -> dict:
"last_watch_sync_at": iso(link.last_watch_sync_at) if link else None, "last_watch_sync_at": iso(link.last_watch_sync_at) if link else None,
} }
@router.get("/watch/link") @router.get("/watch/link")
def watch_link_status( def watch_link_status(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User = Depends(current_user), db: Session = Depends(get_db)
@@ -103,9 +120,11 @@ def watch_link_status(
link = db.query(PlexLink).filter_by(user_id=user.id).first() link = db.query(PlexLink).filter_by(user_id=user.id).first()
return _link_dict(link) return _link_dict(link)
class WatchLinkIn(BaseModel): class WatchLinkIn(BaseModel):
enabled: bool = False enabled: bool = False
@router.post("/watch/link") @router.post("/watch/link")
def watch_link_set( def watch_link_set(
body: WatchLinkIn, body: WatchLinkIn,
@@ -120,7 +139,9 @@ def watch_link_set(
enabled = body.enabled enabled = body.enabled
link = db.query(PlexLink).filter_by(user_id=user.id).first() link = db.query(PlexLink).filter_by(user_id=user.id).first()
if link is None: if link is None:
link = PlexLink(user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc)) link = PlexLink(
user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc)
)
db.add(link) db.add(link)
link.sync_enabled = enabled link.sync_enabled = enabled
db.commit() db.commit()
@@ -130,6 +151,7 @@ def watch_link_set(
result = plex_watch.import_owner_watch_state(db, link) result = plex_watch.import_owner_watch_state(db, link)
return {**_link_dict(link), "import": result or None} return {**_link_dict(link), "import": result or None}
@router.post("/watch/import") @router.post("/watch/import")
def watch_import_now( def watch_import_now(
user: User = Depends(admin_user), db: Session = Depends(get_db) user: User = Depends(admin_user), db: Session = Depends(get_db)
File diff suppressed because it is too large Load Diff
+106 -24
View File
@@ -4,11 +4,20 @@ import hashlib
import os import os
import tempfile import tempfile
from datetime import datetime, timezone from datetime import datetime, timezone
import httpx 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 fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.auth import current_user, resolved_user_id from app.auth import current_user, resolved_user_id
from app.db import SessionLocal, get_db from app.db import SessionLocal, get_db
from app.models import ( from app.models import (
@@ -18,18 +27,16 @@ from app.models import (
PlexState, PlexState,
User, User,
) )
from app.plex import imgcache, subtitles
from app.plex import paths as plex_paths from app.plex import paths as plex_paths
from app.plex import people as plex_people from app.plex import people as plex_people
from app.plex import stream as plex_stream from app.plex import stream as plex_stream
from app.plex import watch_sync as plex_watch from app.plex import watch_sync as plex_watch
from app.plex.client import PlexClient, PlexError, PlexNotConfigured 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"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
from app.routes.plex._shared import (
_item_or_404,
)
def _require_session(request: Request) -> int: def _require_session(request: Request) -> int:
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT """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") raise HTTPException(status_code=401, detail="Not authenticated")
return uid return uid
@router.get("/image/{rating_key}") @router.get("/image/{rating_key}")
def image(rating_key: str, request: Request, variant: str = "thumb") -> Response: 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 """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) key = imgcache.key_for(db, str(rating_key), v)
if not key: if not key:
raise HTTPException(status_code=404, detail="No image") 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: except PlexNotConfigured as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
try: try:
@@ -77,8 +87,11 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
imgcache.write(cache_key, data, ct) imgcache.write(cache_key, data, ct)
return imgcache.response(data, ct) return imgcache.response(data, ct)
@router.get("/subtitle/{rating_key}/{sub_ord}") @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 """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 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) 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: with SessionLocal() as db:
it = _item_or_404(db, rating_key) it = _item_or_404(db, rating_key)
with PlexClient(db) as plex: with PlexClient(db) as plex:
part = ((plex.metadata(it.rating_key) or {}).get("Media") or [{}])[0].get("Part") or [{}] part = ((plex.metadata(it.rating_key) or {}).get("Media") or [{}])[
subs = [s for s in (part[0].get("Stream") or []) if s.get("streamType") == 3] 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): if sub_ord < 0 or sub_ord >= len(subs):
raise HTTPException(status_code=404, detail="No such subtitle") raise HTTPException(status_code=404, detail="No such subtitle")
s = subs[sub_ord] s = subs[sub_ord]
codec = (s.get("codec") or s.get("format") or "").lower() codec = (s.get("codec") or s.get("format") or "").lower()
if codec in subtitles.BITMAP_SUBS: 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 if s.get("key"): # external sidecar → fetch from Plex, convert to VTT
raw = plex.raw_get(s["key"]) raw = plex.raw_get(s["key"])
if codec in ("srt", "subrip", ""): 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) vtt = subtitles.ffmpeg_to_vtt(tmp, None)
finally: finally:
os.unlink(tmp) 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) src = plex_paths.local_media_path(db, it.file_path)
if src is None: if src is None:
raise HTTPException(status_code=404, detail="Media file not found on disk") raise HTTPException(
emb_ord = sum(1 for x in subs[:sub_ord] if x.get("index") is not None) 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) vtt = subtitles.ffmpeg_to_vtt(str(src), emb_ord)
else: else:
raise HTTPException(status_code=404, detail="Subtitle not fetchable") raise HTTPException(
status_code=404, detail="Subtitle not fetchable"
)
except PlexNotConfigured as e: except PlexNotConfigured as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except PlexError as 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) subtitles.cache_write(cache_key, vtt)
return Response(subtitles.shift_vtt(vtt, offset), media_type="text/vtt") return Response(subtitles.shift_vtt(vtt, offset), media_type="text/vtt")
_PROGRESS_MIN_S = 5 _PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30 _FINISH_MARGIN_S = 30
@router.get("/person-image") @router.get("/person-image")
def person_image(u: str, request: Request) -> Response: def person_image(u: str, request: Request) -> Response:
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed). """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) imgcache.write(cache_key, r.content, ct)
return imgcache.response(r.content, ct) return imgcache.response(r.content, ct)
def _push_watch( def _push_watch(
background: BackgroundTasks, background: BackgroundTasks,
db: Session, db: Session,
@@ -173,10 +204,17 @@ def _push_watch(
# lands before it settles synced_to_plex (which would otherwise strand that newer value). # 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() st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first()
background.add_task( 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), plex_watch._state_marker(st),
) )
class ItemProgressIn(BaseModel): class ItemProgressIn(BaseModel):
# float, truncated in the handler — the player may post a raw currentTime (a float); a plain # 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). # `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 duration_seconds: float = 0
final: bool = False final: bool = False
@router.post("/item/{rating_key}/progress") @router.post("/item/{rating_key}/progress")
def item_progress( def item_progress(
rating_key: str, rating_key: str,
@@ -241,13 +280,22 @@ def item_progress(
db.commit() db.commit()
if final: if final:
_push_watch( _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} return {"position_seconds": position}
class ItemStateIn(BaseModel): class ItemStateIn(BaseModel):
status: str = "" status: str = ""
@router.post("/item/{rating_key}/state") @router.post("/item/{rating_key}/state")
def item_state( def item_state(
rating_key: str, rating_key: str,
@@ -285,8 +333,13 @@ def item_state(
_push_watch(background, db, user.id, it.id, rating_key, "watched") _push_watch(background, db, user.id, it.id, rating_key, "watched")
return {"status": status} return {"status": status}
def _bulk_state( 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: ) -> int:
"""Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a """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.""" linked Plex account in the background (best-effort). Returns how many rows actually changed."""
@@ -294,7 +347,8 @@ def _bulk_state(
existing = { existing = {
s.item_id: s s.item_id: s
for s in db.query(PlexState).filter( 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 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) background.add_task(plex_watch.push_bulk_state_to_plex, user_id, to_push)
return len(to_push) return len(to_push)
class WatchedIn(BaseModel): class WatchedIn(BaseModel):
watched: bool = False watched: bool = False
@router.post("/show/{rating_key}/state") @router.post("/show/{rating_key}/state")
def show_state( def show_state(
rating_key: str, rating_key: str,
@@ -344,6 +400,7 @@ def show_state(
changed = _bulk_state(background, db, user.id, eps, watched) changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched} return {"changed": changed, "watched": watched}
@router.post("/season/{rating_key}/state") @router.post("/season/{rating_key}/state")
def season_state( def season_state(
rating_key: str, rating_key: str,
@@ -361,6 +418,7 @@ def season_state(
changed = _bulk_state(background, db, user.id, eps, watched) changed = _bulk_state(background, db, user.id, eps, watched)
return {"changed": changed, "watched": watched} return {"changed": changed, "watched": watched}
@router.post("/stream/{rating_key}/session") @router.post("/stream/{rating_key}/session")
def stream_session( def stream_session(
rating_key: str, rating_key: str,
@@ -378,11 +436,17 @@ def stream_session(
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
it = _item_or_404(db, rating_key) it = _item_or_404(db, rating_key)
if it.playable == "transcode": 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 # `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. # 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: 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: if plex_paths.local_media_path(db, it.file_path) is None:
raise HTTPException(status_code=404, detail="Media file not found on disk") raise HTTPException(status_code=404, detail="Media file not found on disk")
s = plex_stream.start_session(db, it, start, audio, aoff, multi) 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") 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 # 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. # 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") @router.get("/stream/{rating_key}/file")
def stream_file( def stream_file(
@@ -409,11 +478,22 @@ def stream_file(
if download: if download:
# A clean, safe download name from the title + the real file extension. # A clean, safe download name from the title + the real file extension.
ext = src.suffix or (f".{it.container}" if it.container else "") 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), filename=f"{base}{ext}")
return FileResponse(str(src)) # Starlette honours the Range header (206) 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}") @router.get("/stream/{rating_key}/hls/{filename}")
def stream_hls_file( def stream_hls_file(
@@ -446,5 +526,7 @@ def stream_hls_file(
if not plex_stream.wait_for(path, timeout): if not plex_stream.wait_for(path, timeout):
raise HTTPException(status_code=404, detail="Not available yet") raise HTTPException(status_code=404, detail="Not available yet")
return Response( 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"},
) )