feat(plex): P2 streaming backend — direct 206 + seek-restart HLS remux
Validated end-to-end on a real remux file (h264+ac3 mkv). The riskiest part of the
Plex epic — on-the-fly playback from the local file — now proven.
- app/plex/stream.py: seek-restart HLS session model (one ffmpeg per item, restarted
at a seek offset via -ss). Video stream-COPY (I/O-bound, CPU-light — fine on the
GPU-less prod host) + audio→aac only when not already aac. Maps first video+audio,
drops subtitles (no VTT rendition clutter). Session cap + idle reaper.
- routes: POST /stream/{rk}/session?start= (direct→raw url, remux→HLS session,
transcode→501 P3), GET /stream/{rk}/file (206 range, direct), /index.m3u8, /seg_n.ts.
- KEY FINDINGS (hard-won): (1) ffmpeg -hls_playlist_type VOD does NOT write the
playlist mid-run (only at finalize) → use EVENT (append-only, appears immediately),
which suits the seek-restart model; (2) naive per-segment copy + keyframe-indexed
extraction are both unreliable (non-uniform/imprecise segments) — ffmpeg's own HLS
muxer is the only correct segmenter; (3) dev slowness was the Windows /downloads
bind-mount write + SMB read → PLEX_HLS_DIR env points scratch at fast container-local
/var/tmp on dev (prod keeps download_root, fast local disk).
This commit is contained in:
parent
219a935121
commit
4e9b18cda9
4 changed files with 263 additions and 0 deletions
|
|
@ -8,6 +8,7 @@ catalog sync.
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
|
|
@ -15,6 +16,8 @@ from app import sysconfig
|
|||
from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User
|
||||
from app.plex import paths as plex_paths
|
||||
from app.plex import stream as plex_stream
|
||||
from app.plex import sync as plex_sync
|
||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||
from app.routes.admin import admin_user
|
||||
|
|
@ -279,3 +282,92 @@ def image(
|
|||
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"})
|
||||
|
||||
|
||||
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
||||
|
||||
|
||||
def _item_or_404(db: Session, rating_key: str) -> PlexItem:
|
||||
it = db.query(PlexItem).filter_by(rating_key=str(rating_key)).first()
|
||||
if it is None:
|
||||
raise HTTPException(status_code=404, detail="Unknown Plex item")
|
||||
return it
|
||||
|
||||
|
||||
@router.post("/stream/{rating_key}/session")
|
||||
def stream_session(
|
||||
rating_key: str,
|
||||
start: float = 0.0,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
||||
(re)start an HLS session at the offset (seek-restart); transcode (HEVC/VP9) is P3."""
|
||||
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||
it = _item_or_404(db, rating_key)
|
||||
if it.playable == "direct":
|
||||
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
|
||||
if it.playable == "transcode":
|
||||
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
||||
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)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/index.m3u8"}
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/file")
|
||||
def stream_file(
|
||||
rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""Serve the raw local media file with HTTP range support (direct-playable files)."""
|
||||
it = _item_or_404(db, rating_key)
|
||||
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")
|
||||
return FileResponse(str(src)) # Starlette honours the Range header (206)
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/index.m3u8")
|
||||
def stream_playlist(
|
||||
rating_key: str,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""The current HLS session's playlist (starts a session at 0 if none is running)."""
|
||||
s = plex_stream.current_session(str(rating_key))
|
||||
if s is None:
|
||||
it = _item_or_404(db, rating_key)
|
||||
if it.playable not in ("remux",):
|
||||
raise HTTPException(status_code=400, detail="Not an HLS-remux item")
|
||||
s = plex_stream.start_session(db, it, 0.0)
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||
if not plex_stream.wait_for(s.dir / "index.m3u8", 30):
|
||||
raise HTTPException(status_code=503, detail="Playlist not ready")
|
||||
return Response(
|
||||
content=(s.dir / "index.m3u8").read_bytes(),
|
||||
media_type="application/vnd.apple.mpegurl",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stream/{rating_key}/seg_{n}.ts")
|
||||
def stream_segment(
|
||||
rating_key: str,
|
||||
n: int,
|
||||
user: User = Depends(current_user),
|
||||
) -> Response:
|
||||
"""Serve one HLS segment from the current session (waits briefly if it's just ahead)."""
|
||||
s = plex_stream.current_session(str(rating_key))
|
||||
if s is None:
|
||||
raise HTTPException(status_code=404, detail="No active playback session")
|
||||
seg = s.dir / f"seg_{n}.ts"
|
||||
if not plex_stream.wait_for(seg, 20):
|
||||
raise HTTPException(status_code=404, detail="Segment not available")
|
||||
return Response(
|
||||
content=seg.read_bytes(), media_type="video/mp2t", headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue