feat(plex): P2 subtitle + audio track selector (closes P2)

Backend: item detail returns audio_streams + subtitle_streams (from Plex Part.Stream
ordinals). The HLS session accepts audio/subtitle stream ordinals — the selected audio
is mapped (transcoded to AAC), a selected subtitle is muxed in as a WebVTT rendition
via a MASTER playlist (-master_pl_name + -var_stream_map sgroup:subs) so ffmpeg keeps
it in sync per-session. Selecting a track forces the HLS path (even for direct files).
Generic /stream/{rk}/hls/{filename} endpoint serves the master/media/vtt/ts artifacts.

Frontend: gear menu lists real audio + subtitle tracks; changing one restarts the
session at the current position (seek-restart mechanism). hls.js subtitle enabled on
SUBTITLE_TRACKS_UPDATED (not just MANIFEST_PARSED, else the VTT is never fetched).
Cues auto-nudged to line 88% (the full-height <video> clips a default bottom-edge cue).
Controls no longer auto-hide while the menu is open. plex.player.* i18n en/hu/de.

Verified in a real browser (2 Broke Girls S1E1, 2 audio + 2 subs): menu shows the real
tracks, enabling English subtitles renders them correctly positioned; backend master
playlist + vtt segments validated over HTTP.
This commit is contained in:
npeter83 2026-07-05 06:08:44 +02:00
parent 29d306441a
commit a06f87f5f6
7 changed files with 259 additions and 65 deletions

View file

@ -39,29 +39,33 @@ _sessions: dict[str, "HlsSession"] = {}
class HlsSession:
def __init__(self, key: str, directory: Path, proc: subprocess.Popen, start_s: float):
def __init__(self, key: str, directory: Path, proc: subprocess.Popen, start_s: float, entry: str):
self.key = key
self.dir = directory
self.proc = proc
self.start_s = start_s
self.entry = entry # the playlist filename hls.js should load (master.m3u8 with subs, else index.m3u8)
self.last_access = time.time()
def _acodec(item: PlexItem) -> str:
# Copy the audio when it's already AAC; otherwise transcode (ac3/dts/… → aac). Video is always
# copied for the remux class.
return "copy" if (item.codec_audio or "").lower() == "aac" else "aac"
def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, acodec: str) -> list[str]:
def _ffmpeg_cmd(
src: Path, start_s: float, out_dir: Path, audio_ord: int | None, sub_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
# Map only the first video + first audio; drop subtitles so ffmpeg's HLS muxer doesn't spawn
# WebVTT renditions (subtitle/multi-audio selection is a later refinement served separately).
args += ["-i", str(src), "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-c:v", "copy", "-c:a", acodec]
if acodec == "aac":
args += ["-ac", "2", "-b:a", "192k"]
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"]
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),
@ -72,9 +76,12 @@ def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, acodec: str) -> list[s
"-hls_playlist_type", "event",
"-hls_segment_type", "mpegts",
"-hls_flags", "independent_segments+temp_file",
"-hls_segment_filename", str(out_dir / "seg_%d.ts"),
str(out_dir / "index.m3u8"),
]
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
@ -99,31 +106,39 @@ def _enforce_cap() -> None:
_sessions.pop(key, None)
def start_session(db: DbSession, item: PlexItem, start_s: float) -> HlsSession | None:
"""(Re)start the HLS remux for an item at the given offset. Returns None if the local file
can't be read."""
def start_session(
db: DbSession,
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."""
src = paths.local_media_path(db, item.file_path)
if src is None:
return None
key = item.rating_key
start_s = max(0.0, float(start_s))
acodec = _acodec(item)
with _lock:
old = _sessions.pop(key, None)
if old is not None:
_kill(old)
directory = _HLS_ROOT / f"{key}_{int(start_s)}"
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{audio_ord}_{sub_ord}"
shutil.rmtree(directory, ignore_errors=True)
directory.mkdir(parents=True, exist_ok=True)
proc = subprocess.Popen(
_ffmpeg_cmd(src, start_s, directory, acodec),
_ffmpeg_cmd(src, start_s, directory, audio_ord, sub_ord),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
s = HlsSession(key, directory, proc, start_s)
entry = "master.m3u8" if sub_ord is not None else "index.m3u8"
s = HlsSession(key, directory, proc, start_s, entry)
_sessions[key] = s
_enforce_cap()
log.info("plex hls session start key=%s start=%.1f acodec=%s", key, start_s, acodec)
log.info(
"plex hls session start key=%s start=%.1f audio=%s sub=%s", key, start_s, audio_ord, sub_ord
)
return s

View file

@ -331,6 +331,8 @@ def item_detail(
cast: list[str] = []
markers: list[dict] = []
audio_streams: list[dict] = []
subtitle_streams: list[dict] = []
try:
with PlexClient(db) as plex:
meta = plex.metadata(it.rating_key, markers=True) or {}
@ -344,6 +346,26 @@ def item_detail(
"end_s": round((m.get("endTimeOffset") or 0) / 1000, 1),
}
)
# Audio + subtitle tracks (ordinals among their stream type — used to -map the selection).
part = ((meta.get("Media") or [{}])[0].get("Part") or [{}])[0]
for s in part.get("Stream") or []:
if s.get("streamType") == 2:
audio_streams.append(
{
"ord": len(audio_streams),
"label": s.get("displayTitle") or s.get("language") or f"Audio {len(audio_streams) + 1}",
"language": s.get("languageTag") or s.get("language"),
"default": bool(s.get("selected") or s.get("default")),
}
)
elif s.get("streamType") == 3:
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"),
}
)
except (PlexError, PlexNotConfigured):
pass # metadata extras are best-effort; playback works without them
@ -361,6 +383,8 @@ def item_detail(
"art": f"/api/plex/image/{it.rating_key}?variant=art",
"cast": cast,
"markers": markers,
"audio_streams": audio_streams,
"subtitle_streams": subtitle_streams,
"status": st.status if st else "new",
"position_seconds": st.position_seconds if st else 0,
"show_title": show.title if show else None,
@ -451,23 +475,28 @@ def item_state(
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."""
(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."""
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).")
select = audio is not None or subtitle is not None
if it.playable == "direct" and not select:
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)
s = plex_stream.start_session(db, it, start, audio, subtitle)
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"}
return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
@router.get("/stream/{rating_key}/file")
@ -492,43 +521,35 @@ def stream_file(
return FileResponse(str(src)) # Starlette honours the Range header (206)
@router.get("/stream/{rating_key}/index.m3u8")
def stream_playlist(
_HLS_CT = {".m3u8": "application/vnd.apple.mpegurl", ".ts": "video/mp2t", ".vtt": "text/vtt"}
@router.get("/stream/{rating_key}/hls/{filename}")
def stream_hls_file(
rating_key: str,
filename: 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)."""
"""Serve a file (playlist / segment / subtitle) from the current HLS session directory. The
playlist references its segments/subtitles by relative name, all resolving back here."""
# Filename must be a plain hls artifact — no path separators, only the expected extensions.
ext = "." + filename.rsplit(".", 1)[-1] if "." in filename else ""
if ext not in _HLS_CT or "/" in filename or "\\" in filename or ".." in filename:
raise HTTPException(status_code=404, detail="Not found")
s = plex_stream.current_session(str(rating_key))
if s is None:
# No session yet: only the entry playlist for a remux item may auto-start one at 0.
it = _item_or_404(db, rating_key)
if it.playable not in ("remux",):
raise HTTPException(status_code=400, detail="Not an HLS-remux item")
if filename != "index.m3u8" or it.playable != "remux":
raise HTTPException(status_code=404, detail="No active playback session")
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")
path = s.dir / filename
timeout = 30 if ext == ".m3u8" else 20
if not plex_stream.wait_for(path, timeout):
raise HTTPException(status_code=404, detail="Not available yet")
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"}
content=path.read_bytes(), media_type=_HLS_CT[ext], headers={"Cache-Control": "no-store"}
)