diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index e45c826..8e697a0 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -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 diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 065a2a1..b16d78b 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -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"} ) diff --git a/frontend/src/components/PlexPlayer.tsx b/frontend/src/components/PlexPlayer.tsx index 428cc2c..083f28a 100644 --- a/frontend/src/components/PlexPlayer.tsx +++ b/frontend/src/components/PlexPlayer.tsx @@ -10,6 +10,7 @@ import { Pause, Play, RotateCcw, + Settings, SkipBack, SkipForward, Volume2, @@ -57,9 +58,25 @@ export default function PlexPlayer({ itemId, onClose }: Props) { const [ready, setReady] = useState(false); const [uiVisible, setUiVisible] = useState(true); const hideTimer = useRef(null); + // Selected audio / subtitle stream ordinals (null audio = default first; null subtitle = off). + const [audioOrd, setAudioOrd] = useState(null); + const [subOrd, setSubOrd] = useState(null); + const [menuOpen, setMenuOpen] = useState(false); + const audioRef = useRef(null); + const subRef = useRef(null); + const menuOpenRef = useRef(false); + audioRef.current = audioOrd; + subRef.current = subOrd; + menuOpenRef.current = menuOpen; durationRef.current = duration; + // A new item starts with default tracks (its stream layout differs from the last one's). + useEffect(() => { + setAudioOrd(null); + setSubOrd(null); + }, [id]); + // --- media setup: (re)load a session for `id` at a start offset ------------------------------- const loadSession = useCallback( async (startAt: number) => { @@ -68,7 +85,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) { setReady(false); let sess; try { - sess = await api.plexSession(id, startAt); + sess = await api.plexSession(id, startAt, audioRef.current, subRef.current); } catch { return; } @@ -82,7 +99,16 @@ export default function PlexPlayer({ itemId, onClose }: Props) { hlsRef.current = hls; hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`); hls.attachMedia(video); + // Show the (single) muxed subtitle rendition when one was requested, else none. The + // subtitle tracks may not be known yet at MANIFEST_PARSED, so also enable on the dedicated + // event (otherwise hls.js never fetches the WebVTT segments). + const enableSubs = () => { + hls.subtitleDisplay = subRef.current != null; + hls.subtitleTrack = subRef.current != null ? 0 : -1; + }; + hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, enableSubs); hls.on(Hls.Events.MANIFEST_PARSED, () => { + enableSubs(); setReady(true); video.play().catch(() => {}); }); @@ -233,6 +259,61 @@ export default function PlexPlayer({ itemId, onClose }: Props) { a.remove(); }, [id]); + // Switching audio/subtitle re-maps the track server-side, so it restarts the session at the + // current position (like a seek). The refs are updated synchronously so loadSession sees them. + const changeAudio = useCallback( + (ord: number | null) => { + audioRef.current = ord; + setAudioOrd(ord); + setMenuOpen(false); + loadSession(absRef.current); + }, + [loadSession], + ); + const changeSubtitle = useCallback( + (ord: number | null) => { + subRef.current = ord; + setSubOrd(ord); + setMenuOpen(false); + loadSession(absRef.current); + }, + [loadSession], + ); + + // Lift subtitle cues off the very bottom edge. The video element fills the viewport height, so a + // default (line "auto") cue renders at the extreme bottom and gets clipped — nudge every cue up + // to ~88% so it sits just above the control bar. Re-applied on each cue change (hls.js streams + // cues in as subtitle segments load, and TextTrack has no per-cue "added" event). + useEffect(() => { + const video = videoRef.current; + if (!video) return; + const placeAll = () => { + for (const tr of Array.from(video.textTracks)) { + for (const c of Array.from(tr.cues || [])) { + try { + (c as VTTCue).snapToLines = false; + (c as VTTCue).line = 88; + } catch { + /* not a positionable cue */ + } + } + } + }; + const bind = (tr: TextTrack) => tr.addEventListener("cuechange", placeAll); + for (const tr of Array.from(video.textTracks)) bind(tr); + const onAddTrack = (e: TrackEvent) => { + if (e.track) { + bind(e.track as TextTrack); + placeAll(); + } + }; + video.textTracks.addEventListener("addtrack", onAddTrack as EventListener); + return () => { + video.textTracks.removeEventListener("addtrack", onAddTrack as EventListener); + for (const tr of Array.from(video.textTracks)) tr.removeEventListener("cuechange", placeAll); + }; + }, [id]); + // Auto-advance to the next episode when one finishes. useEffect(() => { const video = videoRef.current; @@ -250,7 +331,8 @@ export default function PlexPlayer({ itemId, onClose }: Props) { setUiVisible(true); if (hideTimer.current) window.clearTimeout(hideTimer.current); hideTimer.current = window.setTimeout(() => { - if (!videoRef.current?.paused) setUiVisible(false); + // Keep the controls up while the audio/subtitle menu is open, else it would vanish mid-choice. + if (!videoRef.current?.paused && !menuOpenRef.current) setUiVisible(false); }, 3000); }, []); @@ -446,6 +528,61 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
+ {detail && (detail.audio_streams.length > 1 || detail.subtitle_streams.length > 0) && ( +
+ + {menuOpen && ( +
+ {detail.audio_streams.length > 1 && ( + <> +
+ {t("plex.player.audio")} +
+ {detail.audio_streams.map((a) => ( + + ))} + + )} +
+ {t("plex.player.subtitles")} +
+ + {detail.subtitle_streams.map((s) => ( + + ))} +
+ )} +
+ )} diff --git a/frontend/src/i18n/locales/de/plex.json b/frontend/src/i18n/locales/de/plex.json index 3057c47..89c013c 100644 --- a/frontend/src/i18n/locales/de/plex.json +++ b/frontend/src/i18n/locales/de/plex.json @@ -43,7 +43,11 @@ "next": "Nächste Folge", "mute": "Stumm (m)", "fullscreen": "Vollbild (f)", - "download": "Originaldatei herunterladen" + "download": "Originaldatei herunterladen", + "tracks": "Audio & Untertitel", + "audio": "Audio", + "subtitles": "Untertitel", + "subOff": "Aus" }, "playable": { "direct": "Spielt direkt im Browser", diff --git a/frontend/src/i18n/locales/en/plex.json b/frontend/src/i18n/locales/en/plex.json index 9dcf85d..1570159 100644 --- a/frontend/src/i18n/locales/en/plex.json +++ b/frontend/src/i18n/locales/en/plex.json @@ -43,7 +43,11 @@ "next": "Next episode", "mute": "Mute (m)", "fullscreen": "Fullscreen (f)", - "download": "Download original file" + "download": "Download original file", + "tracks": "Audio & subtitles", + "audio": "Audio", + "subtitles": "Subtitles", + "subOff": "Off" }, "playable": { "direct": "Plays directly in the browser", diff --git a/frontend/src/i18n/locales/hu/plex.json b/frontend/src/i18n/locales/hu/plex.json index 6038ad7..490e3ed 100644 --- a/frontend/src/i18n/locales/hu/plex.json +++ b/frontend/src/i18n/locales/hu/plex.json @@ -43,7 +43,11 @@ "next": "Következő rész", "mute": "Némítás (m)", "fullscreen": "Teljes képernyő (f)", - "download": "Eredeti fájl letöltése" + "download": "Eredeti fájl letöltése", + "tracks": "Audió és felirat", + "audio": "Audió", + "subtitles": "Felirat", + "subOff": "Ki" }, "playable": { "direct": "Közvetlenül játszható a böngészőben", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 49bccd0..492da09 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -689,6 +689,8 @@ export interface PlexItemDetail { art: string; cast: string[]; markers: PlexMarker[]; + audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[]; + subtitle_streams: { ord: number; label: string; language?: string | null }[]; status: string; position_seconds: number; show_title?: string | null; @@ -1039,10 +1041,17 @@ export const api = { req(`/api/plex/show/${encodeURIComponent(id)}`), plexItem: (id: string): Promise => req(`/api/plex/item/${encodeURIComponent(id)}`), - plexSession: (id: string, start = 0): Promise => - req(`/api/plex/stream/${encodeURIComponent(id)}/session?start=${Math.max(0, Math.floor(start))}`, { - method: "POST", - }), + plexSession: ( + id: string, + start = 0, + audio?: number | null, + subtitle?: number | null, + ): Promise => { + const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) }); + if (audio != null) p.set("audio", String(audio)); + if (subtitle != null) p.set("subtitle", String(subtitle)); + return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" }); + }, plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise => req(`/api/plex/item/${encodeURIComponent(id)}/progress`, { method: "POST",