""" subtitle_parser.py — Subtitle Parsing & Writing (Step 3) Handles .srt / .vtt / .ass via pysubs2. Public API ---------- parse_subtitle(file_bytes, filename) -> (SSAFile, fmt_str) get_plain_lines(ssafile) -> list[str] replace_lines(ssafile, new_lines) -> SSAFile write_subtitle(ssafile, fmt_str) -> bytes clean_workspace() -> None workspace_size_mb() -> float """ import copy import logging import os import re import shutil import tempfile import pysubs2 logger = logging.getLogger(__name__) # ── Workspace dir ────────────────────────────────────────────────────────── WORKSPACE_DIR = os.path.join(tempfile.gettempdir(), "subsync_workspace") os.makedirs(WORKSPACE_DIR, exist_ok=True) # ── Format map ───────────────────────────────────────────────────────────── _EXT_MAP = {".srt": "srt", ".vtt": "vtt", ".ass": "ass", ".ssa": "ass"} def _detect_format(filename: str) -> str: ext = os.path.splitext(filename.lower())[1] fmt = _EXT_MAP.get(ext) if not fmt: raise ValueError( f"'{ext}' format ကို support မလုပ်ပါ — .srt / .vtt / .ass သာ လက်ခံသည်" ) return fmt # ── Parse ────────────────────────────────────────────────────────────────── def parse_subtitle(file_bytes: bytes, filename: str): """ Parse subtitle bytes to pysubs2.SSAFile. Returns (SSAFile, fmt_string). Raises ValueError with user-friendly message on failure. """ fmt = _detect_format(filename) # Decode with multiple encoding fallbacks; strip BOM text = None for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"): try: text = file_bytes.decode(enc) break except (UnicodeDecodeError, LookupError): continue if text is None: raise ValueError("ဖိုင် encoding ဖတ်၍ မရပါ (utf-8 / latin-1 ကြိုးစားပြီး)") text = text.lstrip("\ufeff") # strip BOM if still present # --- Strategy 1: from_string (no format_ arg — let pysubs2 auto-detect) try: subs = pysubs2.SSAFile.from_string(text) logger.info("Parsed %d events from '%s' (%s)", len(subs), filename, fmt) return subs, fmt except Exception as e1: logger.warning("from_string failed (%s): %s", filename, e1) # --- Strategy 2: write to tempfile and load by path try: suffix = os.path.splitext(filename)[1] or ("." + fmt) tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix) try: os.write(tmp_fd, file_bytes) finally: os.close(tmp_fd) subs = pysubs2.SSAFile.load(tmp_path) os.unlink(tmp_path) logger.info("Parsed via tempfile: %d events", len(subs)) return subs, fmt except Exception as e2: logger.error("tempfile parse also failed: %s", e2) raise ValueError( f"Subtitle ဖိုင် ဖတ်၍ မရပါ။\n" f"Attempt 1: {e1}\n" f"Attempt 2: {e2}" ) from e2 # ── Tag stripping ────────────────────────────────────────────────────────── _ASS_TAG_RE = re.compile(r"\{[^}]*\}") _HTML_TAG_RE = re.compile(r"<[^>]+>") def _strip_tags(text: str) -> str: text = _ASS_TAG_RE.sub("", text) text = _HTML_TAG_RE.sub("", text) return text.strip() def get_plain_lines(ssafile) -> list: """ Return list[str] same length as ssafile.events. Tags stripped; \\N/\\n normalised to Python newline. """ lines = [] for event in ssafile.events: raw = event.text.replace("\\N", "\n").replace("\\n", "\n") clean = _strip_tags(raw) lines.append(clean) return lines # ── Replace lines (timing-safe deep copy) ────────────────────────────────── def replace_lines(ssafile, new_lines: list): """ Return new SSAFile with translated text injected index-for-index. TIMESTAMP SAFETY: - event.start / event.end are NEVER touched (deep-copy preserves them) - Only event.text is replaced - If len(new_lines) != len(events): raise immediately — never partial - Empty/whitespace translation → keep original text (safe fallback) - No re-ordering, no shifting, index i always maps to same timestamp """ n_events = len(ssafile.events) n_lines = len(new_lines) if n_lines != n_events: raise ValueError( f"TIMESTAMP SAFETY: cannot replace — " f"translated lines ({n_lines}) != subtitle events ({n_events}). " f"Every line must have a matching translation." ) result = copy.deepcopy(ssafile) # timestamps copied intact fallback_count = 0 for i, event in enumerate(result.events): translated = str(new_lines[i]).strip() if not translated: # Keep original — do NOT leave a blank event (timing stays correct) fallback_count += 1 continue # Restore multi-line joins: Python \n → ASS \N event.text = translated.replace("\n", "\\N") # event.start and event.end are untouched if fallback_count: logger.warning( "replace_lines: %d/%d events kept original (empty translation)", fallback_count, n_events ) return result # ── Write ────────────────────────────────────────────────────────────────── def write_subtitle(ssafile, fmt: str) -> bytes: """Serialise SSAFile to bytes in original format (UTF-8).""" try: return ssafile.to_string(fmt).encode("utf-8") except Exception as exc: raise RuntimeError(f"Subtitle ဖိုင် write မရပါ: {exc}") from exc def output_filename(original_name: str) -> str: """'movie.en.srt' → 'movie.en.my.srt'""" base, ext = os.path.splitext(original_name) return f"{base}.my{ext}" # ── Workspace management ─────────────────────────────────────────────────── def clean_workspace() -> None: """Delete all workspace files (called by Clean Workspace button).""" try: if os.path.exists(WORKSPACE_DIR): shutil.rmtree(WORKSPACE_DIR) os.makedirs(WORKSPACE_DIR, exist_ok=True) except Exception as exc: raise RuntimeError(f"Workspace ရှင်းမရပါ: {exc}") from exc def workspace_size_mb() -> float: total = 0 for dirpath, _, filenames in os.walk(WORKSPACE_DIR): for f in filenames: try: total += os.path.getsize(os.path.join(dirpath, f)) except OSError: pass return round(total / (1024 * 1024), 2)