feat(plex): multi-rendition audio (client-side switch) + reliable resume-on-F5

- Multi-audio items now ship every audio track as an HLS rendition in one session
  (stream.py var_stream_map -> master.m3u8), so hls.js switches audio CLIENT-SIDE with
  no ffmpeg restart, same timeline, no gap/drift. /session gains ?multi=1 (forces HLS
  even for direct-playable multi-audio files); K is probed from the video variant seg.
- Restore the selected audio track on AUDIO_TRACKS_UPDATED, not MANIFEST_PARSED (the
  renditions aren't parsed yet on MANIFEST_PARSED, so the set was dropped -> after F5 the
  UI showed the restored track but playback stayed on the default).
- Resume position now survives F5: a pagehide keepalive beacon (plexProgressBeacon)
  saves the current position on reload/close/navigate, since React effect cleanup does
  not run on a full reload; seekTo writes the target into absRef immediately so a save
  right after a seek is accurate.
This commit is contained in:
npeter83 2026-07-08 22:25:03 +02:00
parent 0a0703b769
commit 3c622fd44c
4 changed files with 155 additions and 43 deletions

View file

@ -1499,6 +1499,7 @@ def stream_session(
start: float = 0.0,
audio: int | None = None,
aoff: float = 0.0,
multi: bool = False,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
@ -1510,11 +1511,13 @@ def stream_session(
it = _item_or_404(db, rating_key)
if it.playable == "transcode":
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
if it.playable == "direct" and audio is None and not aoff:
# `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.
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}
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, aoff)
s = plex_stream.start_session(db, it, start, audio, aoff, multi)
if s is None:
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
@ -1562,11 +1565,15 @@ def stream_hls_file(
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.
# No session yet (e.g. reaped): the ENTRY playlist may auto-start one at 0 — master.m3u8 for a
# multi-audio item, index.m3u8 for a single-audio remux item.
it = _item_or_404(db, rating_key)
if filename != "index.m3u8" or it.playable != "remux":
if filename == "master.m3u8":
s = plex_stream.start_session(db, it, 0.0, multi=True)
elif filename == "index.m3u8" and it.playable == "remux":
s = plex_stream.start_session(db, it, 0.0)
else:
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")
path = s.dir / filename