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

@ -113,6 +113,16 @@ class PlexClient:
items = mc.get("Metadata", []) or []
return items[0] if items else None
def raw_get(self, path: str) -> bytes:
"""Raw bytes of an arbitrary Plex resource path (e.g. an external subtitle stream `key` like
``/library/streams/553184``). Keeps the admin token server-side."""
try:
r = self._http.get(f"{self.base}{path}")
r.raise_for_status()
except httpx.HTTPError as e:
raise PlexError(str(e)) from e
return r.content
def image_bytes(
self, image_path: str, width: int | None = None, height: int | None = None
) -> tuple[bytes, str]:

View file

@ -48,24 +48,17 @@ class HlsSession:
self.last_access = time.time()
def _ffmpeg_cmd(
src: Path, start_s: float, out_dir: Path, audio_ord: int | None, sub_ord: int | None
) -> list[str]:
def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None) -> list[str]:
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
if start_s > 0:
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
args += ["-i", str(src)]
# Video always stream-copied. The selected audio track (default = first) is transcoded to AAC.
# A selected subtitle track is muxed in as a WebVTT rendition (ffmpeg aligns it to this session's
# timeline, so it stays in sync even after a seek-restart); no selection → drop subtitles.
args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?"]
if sub_ord is not None:
args += ["-map", f"0:s:{sub_ord}?", "-c:s", "webvtt"]
else:
args += ["-sn"]
# Video always stream-copied; the selected audio track (default = first) is transcoded to AAC.
# Subtitles are NOT muxed here — they're served as standalone WebVTT tracks (GET /subtitle) that
# the browser overlays, so choosing a subtitle no longer restarts this session (and external
# sidecar subs, which aren't in the file, work — the old `-map 0:s` broke on those).
args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?", "-sn"]
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
if sub_ord is not None:
args += ["-c:s", "webvtt"]
args += [
"-f", "hls",
"-hls_time", str(_SEG_SECONDS),
@ -77,10 +70,6 @@ def _ffmpeg_cmd(
"-hls_segment_type", "mpegts",
"-hls_flags", "independent_segments+temp_file",
]
if sub_ord is not None:
# A subtitle rendition needs a MASTER playlist tying the video variant to the WebVTT group
# (ffmpeg generates the subtitle segments per-session, so they stay in sync after a seek).
args += ["-master_pl_name", "master.m3u8", "-var_stream_map", "v:0,a:0,s:0,sgroup:subs"]
args += ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")]
return args
@ -111,10 +100,10 @@ def start_session(
item: PlexItem,
start_s: float,
audio_ord: int | None = None,
sub_ord: int | None = None,
) -> HlsSession | None:
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio /
subtitle track (ordinals among their stream type). Returns None if the local file can't be read."""
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio track
(ordinal among audio streams). Subtitles are separate WebVTT tracks (not muxed here). Returns
None if the local file can't be read."""
src = paths.local_media_path(db, item.file_path)
if src is None:
return None
@ -124,21 +113,18 @@ def start_session(
old = _sessions.pop(key, None)
if old is not None:
_kill(old)
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{audio_ord}_{sub_ord}"
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{audio_ord}"
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(src, start_s, directory, audio_ord, sub_ord),
_ffmpeg_cmd(src, start_s, directory, audio_ord),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
entry = "master.m3u8" if sub_ord is not None else "index.m3u8"
s = HlsSession(key, directory, proc, start_s, entry)
s = HlsSession(key, directory, proc, start_s, "index.m3u8")
_sessions[key] = s
_enforce_cap()
log.info(
"plex hls session start key=%s start=%.1f audio=%s sub=%s", key, start_s, audio_ord, sub_ord
)
log.info("plex hls session start key=%s start=%.1f audio=%s", key, start_s, audio_ord)
return s

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}"}