fix(plex): play subtitles as WebVTT tracks (external sidecar subs no longer crash)

Selecting a subtitle restarted the HLS session with `-map 0:s:{ord}`, assuming an
EMBEDDED stream. Films whose subs are external sidecar .srt files (Plex reports
them, but they aren't in the mkv) matched no stream; the master-playlist's
declared subtitle group then made ffmpeg fail → "Playback couldn't start".

Subtitles now go through a new GET /api/plex/subtitle/{rk}/{ord} → text/vtt
(external subs fetched from Plex via the stream key + SRT→VTT; embedded text subs
extracted with ffmpeg; image subs → 415), served as native <video><track> that
the browser overlays. So choosing/switching a subtitle is instant with NO session
restart, and stream.py drops all subtitle muxing (`-sn`, no master playlist).
Image-based subs (PGS/VobSub) are marked text=false and hidden in the picker.
Verified on prod's Nymphomaniac Vol. II: HU sidecar → 1693 WebVTT cues, no crash.
This commit is contained in:
npeter83 2026-07-06 08:45:30 +02:00
parent 335e4d7c76
commit c8c027d0fc
7 changed files with 206 additions and 89 deletions

View file

@ -7,6 +7,10 @@ catalog sync.
"""
import hashlib
import logging
import os
import re
import subprocess
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import quote
@ -538,6 +542,45 @@ _CT_EXT = {ct: ext for ext, ct in _EXT_CT}
# strips), so 400px covers 2× DPR; the faint backdrop art only needs ~1280px. Keeps images ~tens of KB.
_IMG_SIZES = {"thumb": (400, 600), "art": (1280, 720)}
# 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"}
def _srt_to_vtt(text: str) -> str:
"""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."""
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"
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
external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams."""
cmd = ["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)
if r.returncode != 0 or not r.stdout:
raise PlexError((r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed")
return r.stdout.decode("utf-8", "replace")
def _sub_cache_read(key: str) -> str | None:
try:
p = _IMG_CACHE / f"{key}.vtt"
return p.read_text(encoding="utf-8") if p.exists() else None
except OSError:
return None
def _sub_cache_write(key: str, vtt: str) -> None:
try:
_IMG_CACHE.mkdir(parents=True, exist_ok=True)
tmp = _IMG_CACHE / f"{key}.vtt.tmp"
tmp.write_text(vtt, encoding="utf-8")
tmp.replace(_IMG_CACHE / f"{key}.vtt") # atomic publish
except OSError:
pass
def _img_cache_read(key: str) -> tuple[bytes, str] | None:
for ext, ct in _EXT_CT:
@ -613,6 +656,57 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
return _img_response(data, ct)
@router.get("/subtitle/{rating_key}/{sub_ord}")
def subtitle(rating_key: str, sub_ord: int, request: Request) -> 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)
AND embedded text subs (extracted from the local file). Image subs (PGS/VobSub) 415. Disk-cached."""
_require_session(request)
cache_key = f"sub_{rating_key}_{sub_ord}"
hit = _sub_cache_read(cache_key)
if hit is not None:
return Response(hit, media_type="text/vtt")
try:
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]
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 _BITMAP_SUBS:
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", ""):
vtt = _srt_to_vtt(raw.decode("utf-8", "replace"))
else:
fd, tmp = tempfile.mkstemp(suffix="." + (codec or "sub"))
try:
os.write(fd, raw)
os.close(fd)
vtt = _ffmpeg_to_vtt(tmp, None)
finally:
os.unlink(tmp)
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)
vtt = _ffmpeg_to_vtt(str(src), emb_ord)
else:
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:
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
_sub_cache_write(cache_key, vtt)
return Response(vtt, media_type="text/vtt")
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
@ -824,11 +918,16 @@ def item_detail(
}
)
elif s.get("streamType") == 3:
codec = (s.get("codec") or s.get("format") or "").lower()
subtitle_streams.append(
{
"ord": len(subtitle_streams),
"label": s.get("displayTitle") or s.get("language") or f"Subtitle {len(subtitle_streams) + 1}",
"language": s.get("languageTag") or s.get("language"),
"codec": codec,
# Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub)
# can't be turned into text, so the player hides them (burn-in is a later phase).
"text": codec not in _BITMAP_SUBS,
}
)
except (PlexError, PlexNotConfigured):
@ -950,24 +1049,22 @@ def stream_session(
rating_key: str,
start: float = 0.0,
audio: int | None = None,
subtitle: int | None = None,
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. Selecting a
non-default audio/subtitle track (`audio`/`subtitle` = stream ordinals) forces the HLS path so
the chosen tracks can be muxed in."""
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
muxed in. Subtitles are independent WebVTT tracks (GET /subtitle) no session restart."""
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).")
select = audio is not None or subtitle is not None
if it.playable == "direct" and not select:
if it.playable == "direct" and audio is None:
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, subtitle)
s = plex_stream.start_session(db, it, start, audio)
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}/hls/{s.entry}"}