Mechanical, whole-tree `ruff format` pass — no behaviour change. Recorded in .git-blame-ignore-revs (next commit) so git blame skips it.
136 lines
4.6 KiB
Python
136 lines
4.6 KiB
Python
"""WebVTT conversion, HLS-clock time-shift, and on-disk cache for Plex subtitle streams.
|
||
|
||
Text subtitles (embedded or external) are turned into WebVTT so the browser <track> can render them;
|
||
bitmap subtitles (Blu-ray/DVD) can't be (would need burn-in). Converted VTTs are cached on the shared
|
||
Plex on-disk cache root (``cache.CACHE_DIR``).
|
||
"""
|
||
|
||
import re
|
||
import subprocess
|
||
|
||
from app.plex.cache import CACHE_DIR
|
||
from app.plex.client import PlexError
|
||
|
||
# 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 _vtt_ts_to_s(ts: str) -> float:
|
||
parts = ts.split(":")
|
||
h = "0" if len(parts) == 2 else parts[0]
|
||
m, rest = parts[-2], parts[-1]
|
||
sec, _, ms = rest.partition(".")
|
||
return int(h) * 3600 + int(m) * 60 + int(sec) + int(ms or 0) / 1000.0
|
||
|
||
|
||
def _vtt_s_to_ts(x: float) -> str:
|
||
x = max(0.0, x)
|
||
h = int(x // 3600)
|
||
x -= h * 3600
|
||
m = int(x // 60)
|
||
x -= m * 60
|
||
s = int(x)
|
||
ms = int(round((x - s) * 1000))
|
||
if ms >= 1000:
|
||
s += 1
|
||
ms -= 1000
|
||
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
||
|
||
|
||
def shift_vtt(vtt: str, offset: float) -> str:
|
||
"""Subtract `offset` seconds from every cue's start/end. WebVTT cues carry ABSOLUTE content times,
|
||
but an HLS remux session's clock is zero-based at the real keyframe (see stream.py media_start_s),
|
||
so a subtitle overlaid on that session must be shifted by that offset to line up with speech.
|
||
|
||
Processes cue BLOCKS so a cue that shifts fully into the past (end <= 0) can be DROPPED entirely.
|
||
(Collapsing them to 00:00:00.000-->00:00:00.000 made EVERY past cue count as active at currentTime 0
|
||
— where a resume/seek lands — piling all of them on screen until playback advanced past 0.) A cue
|
||
straddling 0 is clamped to start at 0."""
|
||
if not offset:
|
||
return vtt
|
||
out_blocks: list[str] = []
|
||
for block in re.split(r"\n[ \t]*\n", vtt):
|
||
if "-->" not in block:
|
||
out_blocks.append(block) # header / NOTE / STYLE — keep verbatim
|
||
continue
|
||
lines = block.split("\n")
|
||
keep = True
|
||
for i, ln in enumerate(lines):
|
||
if "-->" not in ln:
|
||
continue
|
||
left, _, right = ln.partition("-->")
|
||
rparts = right.strip().split(None, 1)
|
||
settings = (" " + rparts[1]) if len(rparts) > 1 else ""
|
||
try:
|
||
st = _vtt_ts_to_s(left.strip()) - offset
|
||
en = _vtt_ts_to_s(rparts[0]) - offset
|
||
except (ValueError, IndexError):
|
||
break # unparseable timing → leave the block as-is
|
||
if en <= 0:
|
||
keep = False # whole cue is before the session start → drop the block
|
||
else:
|
||
lines[i] = (
|
||
f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0
|
||
)
|
||
break
|
||
if keep:
|
||
out_blocks.append("\n".join(lines))
|
||
return "\n\n".join(out_blocks)
|
||
|
||
|
||
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 cache_read(key: str) -> str | None:
|
||
try:
|
||
p = CACHE_DIR / f"{key}.vtt"
|
||
return p.read_text(encoding="utf-8") if p.exists() else None
|
||
except OSError:
|
||
return None
|
||
|
||
|
||
def cache_write(key: str, vtt: str) -> None:
|
||
try:
|
||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
tmp = CACHE_DIR / f"{key}.vtt.tmp"
|
||
tmp.write_text(vtt, encoding="utf-8")
|
||
tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish
|
||
except OSError:
|
||
pass
|