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:
@@ -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,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"}
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+437
-112
File diff suppressed because it is too large
Load Diff
@@ -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"},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user