"""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 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