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:
parent
29d306441a
commit
a06f87f5f6
7 changed files with 259 additions and 65 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue