Merge: promote dev to prod
This commit is contained in:
commit
fc49651eaf
7 changed files with 206 additions and 89 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.28.2
|
0.28.3
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,16 @@ class PlexClient:
|
||||||
items = mc.get("Metadata", []) or []
|
items = mc.get("Metadata", []) or []
|
||||||
return items[0] if items else None
|
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(
|
def image_bytes(
|
||||||
self, image_path: str, width: int | None = None, height: int | None = None
|
self, image_path: str, width: int | None = None, height: int | None = None
|
||||||
) -> tuple[bytes, str]:
|
) -> tuple[bytes, str]:
|
||||||
|
|
|
||||||
|
|
@ -48,24 +48,17 @@ class HlsSession:
|
||||||
self.last_access = time.time()
|
self.last_access = time.time()
|
||||||
|
|
||||||
|
|
||||||
def _ffmpeg_cmd(
|
def _ffmpeg_cmd(src: Path, start_s: float, out_dir: Path, audio_ord: int | None) -> list[str]:
|
||||||
src: Path, start_s: float, out_dir: Path, audio_ord: int | None, sub_ord: int | None
|
|
||||||
) -> list[str]:
|
|
||||||
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
args = ["ffmpeg", "-nostdin", "-loglevel", "error"]
|
||||||
if start_s > 0:
|
if start_s > 0:
|
||||||
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
|
args += ["-ss", f"{start_s:.3f}"] # fast keyframe seek before -i
|
||||||
args += ["-i", str(src)]
|
args += ["-i", str(src)]
|
||||||
# Video always stream-copied. The selected audio track (default = first) is transcoded to AAC.
|
# 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
|
# Subtitles are NOT muxed here — they're served as standalone WebVTT tracks (GET /subtitle) that
|
||||||
# timeline, so it stays in sync even after a seek-restart); no selection → drop subtitles.
|
# the browser overlays, so choosing a subtitle no longer restarts this session (and external
|
||||||
args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?"]
|
# sidecar subs, which aren't in the file, work — the old `-map 0:s` broke on those).
|
||||||
if sub_ord is not None:
|
args += ["-map", "0:v:0", "-map", f"0:a:{audio_ord if audio_ord is not None else 0}?", "-sn"]
|
||||||
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"]
|
args += ["-c:v", "copy", "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
|
||||||
if sub_ord is not None:
|
|
||||||
args += ["-c:s", "webvtt"]
|
|
||||||
args += [
|
args += [
|
||||||
"-f", "hls",
|
"-f", "hls",
|
||||||
"-hls_time", str(_SEG_SECONDS),
|
"-hls_time", str(_SEG_SECONDS),
|
||||||
|
|
@ -77,10 +70,6 @@ def _ffmpeg_cmd(
|
||||||
"-hls_segment_type", "mpegts",
|
"-hls_segment_type", "mpegts",
|
||||||
"-hls_flags", "independent_segments+temp_file",
|
"-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")]
|
args += ["-hls_segment_filename", str(out_dir / "seg_%d.ts"), str(out_dir / "index.m3u8")]
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
@ -111,10 +100,10 @@ def start_session(
|
||||||
item: PlexItem,
|
item: PlexItem,
|
||||||
start_s: float,
|
start_s: float,
|
||||||
audio_ord: int | None = None,
|
audio_ord: int | None = None,
|
||||||
sub_ord: int | None = None,
|
|
||||||
) -> HlsSession | None:
|
) -> HlsSession | None:
|
||||||
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio /
|
"""(Re)start the HLS remux for an item at the given offset, with an optional selected audio track
|
||||||
subtitle track (ordinals among their stream type). Returns None if the local file can't be read."""
|
(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)
|
src = paths.local_media_path(db, item.file_path)
|
||||||
if src is None:
|
if src is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -124,21 +113,18 @@ def start_session(
|
||||||
old = _sessions.pop(key, None)
|
old = _sessions.pop(key, None)
|
||||||
if old is not None:
|
if old is not None:
|
||||||
_kill(old)
|
_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)
|
shutil.rmtree(directory, ignore_errors=True)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
_ffmpeg_cmd(src, start_s, directory, audio_ord, sub_ord),
|
_ffmpeg_cmd(src, start_s, directory, audio_ord),
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
entry = "master.m3u8" if sub_ord is not None else "index.m3u8"
|
s = HlsSession(key, directory, proc, start_s, "index.m3u8")
|
||||||
s = HlsSession(key, directory, proc, start_s, entry)
|
|
||||||
_sessions[key] = s
|
_sessions[key] = s
|
||||||
_enforce_cap()
|
_enforce_cap()
|
||||||
log.info(
|
log.info("plex hls session start key=%s start=%.1f audio=%s", key, start_s, audio_ord)
|
||||||
"plex hls session start key=%s start=%.1f audio=%s sub=%s", key, start_s, audio_ord, sub_ord
|
|
||||||
)
|
|
||||||
return s
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ catalog sync.
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import quote
|
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.
|
# 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)}
|
_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:
|
def _img_cache_read(key: str) -> tuple[bytes, str] | None:
|
||||||
for ext, ct in _EXT_CT:
|
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)
|
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) ----------------------------
|
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -824,11 +918,16 @@ def item_detail(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
elif s.get("streamType") == 3:
|
elif s.get("streamType") == 3:
|
||||||
|
codec = (s.get("codec") or s.get("format") or "").lower()
|
||||||
subtitle_streams.append(
|
subtitle_streams.append(
|
||||||
{
|
{
|
||||||
"ord": len(subtitle_streams),
|
"ord": len(subtitle_streams),
|
||||||
"label": s.get("displayTitle") or s.get("language") or f"Subtitle {len(subtitle_streams) + 1}",
|
"label": s.get("displayTitle") or s.get("language") or f"Subtitle {len(subtitle_streams) + 1}",
|
||||||
"language": s.get("languageTag") or s.get("language"),
|
"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):
|
except (PlexError, PlexNotConfigured):
|
||||||
|
|
@ -950,24 +1049,22 @@ def stream_session(
|
||||||
rating_key: str,
|
rating_key: str,
|
||||||
start: float = 0.0,
|
start: float = 0.0,
|
||||||
audio: int | None = None,
|
audio: int | None = None,
|
||||||
subtitle: int | None = None,
|
|
||||||
user: User = Depends(current_user),
|
user: User = Depends(current_user),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Prepare playback from `start` seconds. Direct-playable files are served raw; remux files
|
"""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
|
(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
|
non-default audio track (`audio` = stream ordinal) forces the HLS path so the chosen audio can be
|
||||||
the chosen tracks can be muxed in."""
|
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
|
plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback
|
||||||
it = _item_or_404(db, rating_key)
|
it = _item_or_404(db, rating_key)
|
||||||
if it.playable == "transcode":
|
if it.playable == "transcode":
|
||||||
raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).")
|
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 audio is None:
|
||||||
if it.playable == "direct" and not select:
|
|
||||||
return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0}
|
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:
|
if plex_paths.local_media_path(db, it.file_path) is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
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:
|
if s is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
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}"}
|
return {"mode": "hls", "start_s": s.start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"}
|
||||||
|
|
|
||||||
|
|
@ -75,10 +75,8 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
const [subOrd, setSubOrd] = useState<number | null>(null);
|
const [subOrd, setSubOrd] = useState<number | null>(null);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const audioRef = useRef<number | null>(null);
|
const audioRef = useRef<number | null>(null);
|
||||||
const subRef = useRef<number | null>(null);
|
|
||||||
const menuOpenRef = useRef(false);
|
const menuOpenRef = useRef(false);
|
||||||
audioRef.current = audioOrd;
|
audioRef.current = audioOrd;
|
||||||
subRef.current = subOrd;
|
|
||||||
menuOpenRef.current = menuOpen;
|
menuOpenRef.current = menuOpen;
|
||||||
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
|
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
|
|
@ -111,7 +109,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
let sess;
|
let sess;
|
||||||
try {
|
try {
|
||||||
sess = await api.plexSession(id, startAt, audioRef.current, subRef.current);
|
sess = await api.plexSession(id, startAt, audioRef.current);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
|
// Surface WHY playback can't start instead of an eternal spinner. 404 = the physical file
|
||||||
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
|
// isn't reachable (missing media bind-mount or a wrong plex_path_map); 501 = the codec
|
||||||
|
|
@ -135,16 +133,8 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
hlsRef.current = hls;
|
hlsRef.current = hls;
|
||||||
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
|
hls.loadSource(`${sess.url}?t=${Math.floor(startAt)}`);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
// Show the (single) muxed subtitle rendition when one was requested, else none. The
|
// Subtitles are native <track>s (not muxed into the HLS), so nothing subtitle-related here.
|
||||||
// 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, () => {
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
enableSubs();
|
|
||||||
setReady(true);
|
setReady(true);
|
||||||
if (aliveRef.current) video.play().catch(() => {});
|
if (aliveRef.current) video.play().catch(() => {});
|
||||||
});
|
});
|
||||||
|
|
@ -322,8 +312,8 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
a.remove();
|
a.remove();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
// Switching audio/subtitle re-maps the track server-side, so it restarts the session at the
|
// Switching AUDIO re-maps the track server-side, so it restarts the session at the current
|
||||||
// current position (like a seek). The refs are updated synchronously so loadSession sees them.
|
// position (like a seek). The ref is updated synchronously so loadSession sees it.
|
||||||
const changeAudio = useCallback(
|
const changeAudio = useCallback(
|
||||||
(ord: number | null) => {
|
(ord: number | null) => {
|
||||||
audioRef.current = ord;
|
audioRef.current = ord;
|
||||||
|
|
@ -333,15 +323,27 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
},
|
},
|
||||||
[loadSession],
|
[loadSession],
|
||||||
);
|
);
|
||||||
const changeSubtitle = useCallback(
|
// Subtitles are native <track>s overlaid by the browser — switching one just flips TextTrack modes
|
||||||
(ord: number | null) => {
|
// (applied by the effect below), NO session restart. Off = null.
|
||||||
subRef.current = ord;
|
const changeSubtitle = useCallback((ord: number | null) => {
|
||||||
setSubOrd(ord);
|
setSubOrd(ord);
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
loadSession(absRef.current);
|
}, []);
|
||||||
},
|
|
||||||
[loadSession],
|
// Text subtitles offered to the browser as <track>s; image subs (PGS/VobSub) can't be shown as text.
|
||||||
);
|
const textSubs = (detail?.subtitle_streams ?? []).filter((s) => s.text);
|
||||||
|
// Apply the selected subtitle: show the matching <track>, disable the rest. The <track> DOM order
|
||||||
|
// matches `textSubs`, so textTracks[i] ↔ textSubs[i]. Setting mode!="disabled" triggers the VTT fetch.
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const tracks = Array.from(video.textTracks);
|
||||||
|
tracks.forEach((tr, i) => {
|
||||||
|
const s = textSubs[i];
|
||||||
|
tr.mode = s && s.ord === subOrd ? "showing" : "disabled";
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [subOrd, id, textSubs.length]);
|
||||||
|
|
||||||
// Lift subtitle cues off the very bottom edge. The video element fills the viewport height, so a
|
// 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
|
// default (line "auto") cue renders at the extreme bottom and gets clipped — nudge every cue up
|
||||||
|
|
@ -484,7 +486,19 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
className="max-h-full max-w-full w-auto h-auto"
|
className="max-h-full max-w-full w-auto h-auto"
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
playsInline
|
playsInline
|
||||||
/>
|
>
|
||||||
|
{/* Text subtitles as native tracks (same-origin → sent with the session cookie). The effect
|
||||||
|
above flips modes; the browser fetches a track's VTT the first time it's shown. */}
|
||||||
|
{textSubs.map((s) => (
|
||||||
|
<track
|
||||||
|
key={s.ord}
|
||||||
|
kind="subtitles"
|
||||||
|
src={api.plexSubtitleUrl(id, s.ord)}
|
||||||
|
srcLang={s.language ?? undefined}
|
||||||
|
label={s.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</video>
|
||||||
|
|
||||||
{!ready && (
|
{!ready && (
|
||||||
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
<div className="absolute inset-0 grid place-items-center px-6 text-center text-sm pointer-events-none">
|
||||||
|
|
@ -621,7 +635,7 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{detail && (detail.audio_streams.length > 1 || detail.subtitle_streams.length > 0) && (
|
{detail && (detail.audio_streams.length > 1 || textSubs.length > 0) && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
onClick={() => setMenuOpen((o) => !o)}
|
||||||
|
|
@ -650,28 +664,32 @@ export default function PlexPlayer({ itemId, onClose }: Props) {
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<div className="px-2 pt-2 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
{textSubs.length > 0 && (
|
||||||
{t("plex.player.subtitles")}
|
<>
|
||||||
</div>
|
<div className="px-2 pt-2 pb-0.5 text-[11px] uppercase tracking-wide text-white/50">
|
||||||
<button
|
{t("plex.player.subtitles")}
|
||||||
onClick={() => changeSubtitle(null)}
|
</div>
|
||||||
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
|
<button
|
||||||
subOrd == null ? "text-accent" : "text-white"
|
onClick={() => changeSubtitle(null)}
|
||||||
}`}
|
className={`block w-full rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||||
>
|
subOrd == null ? "text-accent" : "text-white"
|
||||||
{t("plex.player.subOff")}
|
}`}
|
||||||
</button>
|
>
|
||||||
{detail.subtitle_streams.map((s) => (
|
{t("plex.player.subOff")}
|
||||||
<button
|
</button>
|
||||||
key={s.ord}
|
{textSubs.map((s) => (
|
||||||
onClick={() => changeSubtitle(s.ord)}
|
<button
|
||||||
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
key={s.ord}
|
||||||
subOrd === s.ord ? "text-accent" : "text-white"
|
onClick={() => changeSubtitle(s.ord)}
|
||||||
}`}
|
className={`block w-full truncate rounded px-2 py-1 text-left hover:bg-white/10 ${
|
||||||
>
|
subOrd === s.ord ? "text-accent" : "text-white"
|
||||||
{s.label}
|
}`}
|
||||||
</button>
|
>
|
||||||
))}
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -706,7 +706,8 @@ export interface PlexItemDetail {
|
||||||
tagline?: string | null;
|
tagline?: string | null;
|
||||||
markers: PlexMarker[];
|
markers: PlexMarker[];
|
||||||
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
|
audio_streams: { ord: number; label: string; language?: string | null; default: boolean }[];
|
||||||
subtitle_streams: { ord: number; label: string; language?: string | null }[];
|
// `text`=true → offered as a WebVTT <track>; image subs (PGS/VobSub) are text=false (hidden).
|
||||||
|
subtitle_streams: { ord: number; label: string; language?: string | null; codec?: string; text: boolean }[];
|
||||||
status: string;
|
status: string;
|
||||||
position_seconds: number;
|
position_seconds: number;
|
||||||
show_title?: string | null;
|
show_title?: string | null;
|
||||||
|
|
@ -1152,17 +1153,14 @@ export const api = {
|
||||||
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
req(`/api/plex/show/${encodeURIComponent(id)}`),
|
||||||
plexItem: (id: string): Promise<PlexItemDetail> =>
|
plexItem: (id: string): Promise<PlexItemDetail> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
req(`/api/plex/item/${encodeURIComponent(id)}`),
|
||||||
plexSession: (
|
plexSession: (id: string, start = 0, audio?: number | null): Promise<PlexPlaySession> => {
|
||||||
id: string,
|
|
||||||
start = 0,
|
|
||||||
audio?: number | null,
|
|
||||||
subtitle?: number | null,
|
|
||||||
): Promise<PlexPlaySession> => {
|
|
||||||
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
|
||||||
if (audio != null) p.set("audio", String(audio));
|
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" });
|
return req(`/api/plex/stream/${encodeURIComponent(id)}/session?${p.toString()}`, { method: "POST" });
|
||||||
},
|
},
|
||||||
|
// WebVTT URL for a text subtitle track (used directly as a <track src>). Same-origin → cookie-authed.
|
||||||
|
plexSubtitleUrl: (id: string, ord: number): string =>
|
||||||
|
`/api/plex/subtitle/${encodeURIComponent(id)}/${ord}`,
|
||||||
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
plexProgress: (id: string, position_seconds: number, duration_seconds: number): Promise<unknown> =>
|
||||||
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
req(`/api/plex/item/${encodeURIComponent(id)}/progress`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.28.3",
|
||||||
|
date: "2026-07-06",
|
||||||
|
summary: "Fix: Plex subtitles that wouldn't play — now instant, no restart.",
|
||||||
|
features: [
|
||||||
|
"Plex subtitles: fixed playback failing (\"Playback couldn't start\") when picking a subtitle on films whose subtitles are external sidecar files (very common) — those aren't inside the video, so the old approach couldn't mux them. Subtitles are now served as standalone tracks the browser overlays, so turning one on (or switching) is instant and no longer restarts the video. Image-based subtitles (Blu-ray/DVD) are hidden for now (they can't be shown as text yet).",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.28.2",
|
version: "0.28.2",
|
||||||
date: "2026-07-06",
|
date: "2026-07-06",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue