"""Pure subtitle repair functions. Called by every save path (provider download, embedded extract, post-translate) to normalize content before writing to disk. Pure — no I/O, no DB, no side effects. Any defect it can't repair returns the input unchanged (caller decides whether to fall back to the unrepaired bytes). Five defect classes handled: 1. BOM at file start (UTF-8 BOM 0xEF 0xBB 0xBF) 2. Wrong newline encoding (CRLFCRLF, lone CR) 3. Invalid decimals in timestamps (e.g. `00:00:01,4`) 4. Overlapping cues in SRT 5. Content that is not UTF-8 (legacy codepages, UTF-16) Public API: - repair_bytes(data: bytes, fmt: str) -> bytes - repair_srt(text: str) -> str - repair_ass(text: str) -> str """ from __future__ import annotations import logging import re from subtitle_encoding import decode_subtitle_bytes logger = logging.getLogger(__name__) _UTF8_BOM = b"\xef\xbb\xbf" def repair_bytes(data: bytes, fmt: str) -> bytes: """Entry point for byte-level repair (decode, repair, re-encode). Strips BOM, detects encoding with fallbacks, normalizes newlines, then dispatches to format-specific text repair. """ if not data: return data # Strip UTF-8 BOM if data.startswith(_UTF8_BOM): data = data[len(_UTF8_BOM) :] # Decode — UTF-8 first, then chardet, then Windows-1252. text = decode_subtitle_bytes(data) # Normalize newlines to LF text = text.replace("\r\n", "\n").replace("\r", "\n") # Collapse duplicated blank lines that the newline fix may have introduced text = re.sub(r"\n{3,}", "\n\n", text) # Format-specific repair fmt_lower = (fmt or "").lower() if fmt_lower in ("srt", "vtt"): text = repair_srt(text) elif fmt_lower in ("ass", "ssa"): text = repair_ass(text) return text.encode("utf-8") _SRT_TIMESTAMP_RE = re.compile(r"(\b\d{2}:\d{2}:\d{2}),(\d{1,3})\b") _CUE_LINE_RE = re.compile( r"(\d{2}):(\d{2}):(\d{2}),(\d{3})\s*-->\s*(\d{2}):(\d{2}):(\d{2}),(\d{3})" ) def _pad_decimals(match: re.Match[str]) -> str: hms = match.group(1) ms = match.group(2) return f"{hms},{ms.ljust(3, '0')}" def _ts_to_ms(h: int, m: int, s: int, ms: int) -> int: return h * 3600000 + m * 60000 + s * 1000 + ms def _ms_to_ts(ms_total: int) -> str: if ms_total < 0: ms_total = 0 h, rem = divmod(ms_total, 3600000) m, rem = divmod(rem, 60000) s, ms = divmod(rem, 1000) return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}" def _fix_overlaps_srt(text: str) -> str: """Scan cue lines in document order; clamp overlapping ends.""" matches = list(_CUE_LINE_RE.finditer(text)) if len(matches) < 2: return text replacements: list[tuple[int, int, str]] = [] # (start, end, replacement) for i, match in enumerate(matches[:-1]): h1, m1, s1, ms1, h2, m2, s2, ms2 = map(int, match.groups()) next_match = matches[i + 1] nh1, nm1, ns1, nms1 = map(int, next_match.groups()[:4]) cur_end_ms = _ts_to_ms(h2, m2, s2, ms2) next_start_ms = _ts_to_ms(nh1, nm1, ns1, nms1) if cur_end_ms <= next_start_ms: continue # no overlap new_end_ms = next_start_ms - 1 cur_start_ms = _ts_to_ms(h1, m1, s1, ms1) if new_end_ms <= cur_start_ms: continue # Would produce zero/negative duration — skip fix new_ts = f"{_ms_to_ts(cur_start_ms)} --> {_ms_to_ts(new_end_ms)}" replacements.append((match.start(), match.end(), new_ts)) # Apply replacements back-to-front to preserve earlier offsets for start, end, repl in reversed(replacements): text = text[:start] + repl + text[end:] return text def repair_srt(text: str) -> str: """Repair SRT-specific defects. - Pad 1- and 2-digit millisecond decimals. - Clamp overlapping cue ends to (next_start - 1ms). """ text = _SRT_TIMESTAMP_RE.sub(_pad_decimals, text) text = _fix_overlaps_srt(text) return text def repair_ass(text: str) -> str: """Repair ASS/SSA-specific defects.""" # Currently no ASS-specific repairs needed beyond byte-level. return text