# backend/subtitle_processor.py """Central subtitle processing orchestrator.""" from __future__ import annotations import logging import os import re import shutil from dataclasses import dataclass, field from enum import StrEnum import pysubs2 from subtitle_encoding import load_subtitle_file from subtitle_types import Change, _ms_to_str logger = logging.getLogger(__name__) SUPPORTED_EXTENSIONS = {".srt", ".ass", ".ssa", ".vtt"} class ModName(StrEnum): COMMON_FIXES = "common_fixes" HI_REMOVAL = "hi_removal" CREDIT_REMOVAL = "credit_removal" _MOD_ORDER = [ModName.COMMON_FIXES, ModName.HI_REMOVAL, ModName.CREDIT_REMOVAL] @dataclass class ModConfig: mod: ModName options: dict = field(default_factory=dict) @dataclass class ProcessingResult: changes: list[Change] backed_up: bool output_path: str dry_run: bool def _make_bak_path(path: str) -> str: """Return the canonical backup path for ``path``. Thin wrapper around :func:`subtitle_filename.bak_path_for` so callers inside this module keep their familiar import. The bak target lives under ``/.sublarr/backups/`` to stay invisible to media servers that would otherwise misparse ``bak`` as the Bashkir ISO-639 code. """ from subtitle_filename import bak_path_for return bak_path_for(path) def apply_mods(path: str, mods: list[ModConfig], dry_run: bool = False) -> ProcessingResult: if not os.path.exists(path): raise FileNotFoundError(f"File not found: {path}") ext = os.path.splitext(path)[1].lower() if ext not in SUPPORTED_EXTENSIONS: raise ValueError(f"Unsupported format: {ext!r}. Supported: {SUPPORTED_EXTENSIONS}") mods_by_name = {m.mod: m for m in mods} ordered_mods = [mods_by_name[n] for n in _MOD_ORDER if n in mods_by_name] fmt = ext.lstrip(".") # ".srt" → "srt", ".ass" → "ass", etc. subs = load_subtitle_file(path, format_=fmt) all_changes: list[Change] = [] for mod_config in ordered_mods: if mod_config.mod == ModName.COMMON_FIXES: from common_fixes import apply_common_fixes changes = apply_common_fixes(subs, mod_config.options, fmt=fmt) elif mod_config.mod == ModName.HI_REMOVAL: changes = _apply_hi_removal(subs, mod_config.options, fmt=fmt) elif mod_config.mod == ModName.CREDIT_REMOVAL: changes = _apply_credit_removal(subs, mod_config.options) else: changes = [] all_changes.extend(changes) backed_up = False if not dry_run and all_changes: from subtitle_filename import find_existing_bak # find_existing_bak checks the canonical hidden location AND the legacy # sibling one. Checking only the canonical path (as this did) meant that # after the backups moved to .sublarr/backups/ every pre-migration file # looked un-backed-up — so the "pristine" copy was silently re-created # from an ALREADY-processed file, destroying the only record of the # original. The bak is a single-slot undo; it must never be rewritten # once one exists anywhere. if find_existing_bak(path) is None: bak_path = _make_bak_path(path) os.makedirs(os.path.dirname(bak_path), exist_ok=True) shutil.copy2(path, bak_path) backed_up = True from utils.atomic_write import atomic_save_subs atomic_save_subs(subs, path, format_=fmt, encoding="utf-8") return ProcessingResult( changes=all_changes, backed_up=backed_up, output_path=path, dry_run=dry_run, ) _ASS_OVERRIDE_RE = re.compile(r"\{[^}]*\}") # Private-use sentinels around the tag index. They are neither word characters # nor capitals nor punctuation, so no HI pattern can match through a masked tag # or mistake one for content. _MASK_OPEN = "" _MASK_CLOSE = "" _TAG_MASK_RE = re.compile(_MASK_OPEN + r"(\d+)" + _MASK_CLOSE) def _mask_ass_tags(text: str) -> tuple[str, list[str]]: """Replace ASS override blocks with placeholders so text edits cannot eat them.""" tags: list[str] = [] def _stash(match: re.Match) -> str: tags.append(match.group(0)) return f"{_MASK_OPEN}{len(tags) - 1}{_MASK_CLOSE}" return _ASS_OVERRIDE_RE.sub(_stash, text), tags def _unmask_ass_tags(text: str, tags: list[str]) -> str: """Put the override blocks back where their placeholders ended up.""" return _TAG_MASK_RE.sub(lambda m: tags[int(m.group(1))], text) def _apply_hi_removal(subs: pysubs2.SSAFile, options: dict, fmt: str = "srt") -> list[Change]: from hi_remover import remove_hi_markers is_ass = fmt in ("ass", "ssa") opts = { "square_brackets": True, "round_brackets": True, "curly_brackets": True, "japanese_parens": True, "music_symbols": True, "speaker_labels": True, "all_caps_lines": True, "all_caps_min_length": 4, "music_hash_lines": True, **options, } caps_pattern = _build_all_caps_pattern(subs, opts, is_ass=is_ass) extra_patterns = [] if opts["curly_brackets"]: extra_patterns.append(re.compile(r"\{[^}]{3,}\}", re.IGNORECASE)) if opts["japanese_parens"]: extra_patterns.append(re.compile(r"[\uFF08][^\uFF09]{1,}[\uFF09]")) if opts["music_hash_lines"]: extra_patterns.append(re.compile(r"^[#♪♫\s]+$", re.MULTILINE)) changes: list[Change] = [] indices_to_remove: list[int] = [] _mark_multiline_bracket_events(subs, indices_to_remove) for i, event in enumerate(subs.events): if event.is_comment or i in indices_to_remove: continue original = event.plaintext.strip() if not original: continue # On ASS/SSA the override blocks ({\an8}, {\i1}, karaoke) are masked out # before the HI patterns run and restored afterwards. Reading plaintext # and writing the result back into event.text used to drop every tag on # any cue the pipeline touched — positioning and italics simply vanished. # # SRT keeps working on plaintext exactly as before. Feeding it raw # event.text instead would rewrite every cue containing an escape such as # \h (a hard space, which some SRTs use to align text): the comparison # against plaintext would always differ, and the rewrite turned the # escapes into literal runs of spaces. Only ASS needs the tag treatment, # and on SRT a literal {...} is not formatting — curly_brackets is meant # to remove it. if is_ass: work, tags = _mask_ass_tags(event.text) work = work.replace("\\N", "\n").replace("\\h", " ") else: work, tags = original, [] cleaned = remove_hi_markers(work) for pat in extra_patterns: cleaned = pat.sub("", cleaned).strip() # The caps rule judges the WHOLE cue, not each line on its own. A # capitalised sentence wrapped across two subtitle lines has a first line # that carries no punctuation of its own — judged line by line it looks # like a marker, and half the sentence was deleted: # "UM DIE DREHBUCHSCHREIBERIN\nZU BESCHÜTZEN, ODER?" -> "ZU BESCHÜTZEN, ODER?" # Joined, the comma and the question mark identify it as prose. The # class admits \s, so a genuine two-line marker still matches as a whole. if caps_pattern is not None and caps_pattern.match(cleaned.strip()): cleaned = "" cleaned = cleaned.strip() if is_ass: cleaned = _unmask_ass_tags(cleaned, tags).replace("\n", "\\N") cleaned_plain = _ASS_OVERRIDE_RE.sub("", cleaned).replace("\\N", "\n").strip() else: cleaned_plain = cleaned if cleaned_plain != original: ts = f"{_ms_to_str(event.start)} --> {_ms_to_str(event.end)}" changes.append(Change(i, ts, original, cleaned_plain, "hi_removal")) if cleaned_plain: event.text = cleaned else: indices_to_remove.append(i) for i in sorted(set(indices_to_remove), reverse=True): del subs.events[i] for c in changes: if c.event_index > i: c.event_index -= 1 return changes def _mark_multiline_bracket_events(subs: pysubs2.SSAFile, to_remove: list[int]) -> None: import re open_pattern = re.compile(r"\[[\w\s]+$") close_pattern = re.compile(r"^[\w\s]+\]") for i in range(len(subs.events) - 1): a = subs.events[i].plaintext.strip() b = subs.events[i + 1].plaintext.strip() if open_pattern.search(a) and close_pattern.match(b): if i not in to_remove: to_remove.append(i) if (i + 1) not in to_remove: to_remove.append(i + 1) def _build_all_caps_pattern(subs: pysubs2.SSAFile, opts: dict, is_ass: bool = False): """Return the all-caps HI-marker pattern, or None when the rule must not run. Mirrors Subzero's ``HI_all_caps`` (the mod Bazarr uses):: (?u)(^(?=.*[A-ZÀ-Ž&+]{4,})[A-ZÀ-Ž-_\\s&+]+$) # guard: not only_uppercase The character class admits only capitals, spaces and ``-_&+``. Sentence punctuation is deliberately absent: a capitalised line carrying a comma or a question mark is a *sentence*, not a sound cue. The previous check was ``text == text.upper()``, and punctuation is immune to ``.upper()`` — so fully capitalised dialogue and anime song lyrics (OP/ED karaoke is conventionally typeset in caps) were deleted outright. Subzero's ``only_uppercase`` guard is honoured too: when the whole subtitle is capitalised that is a typesetting style, not a stream of HI markers, and applying the rule would empty the file. The rule is SRT-only. ASS has real typesetting: a capitalised line there is a *sign* — positioned and styled (``\\an8``, ``\\pos``, its own style) — or song karaoke. Capitalisation carries no HI meaning in a format that can express intent directly, and applying the heuristic would delete exactly the content the typesetter put there. """ import re if is_ass or not opts.get("all_caps_lines"): return None texts = [e.plaintext.strip() for e in subs.events if not e.is_comment and e.plaintext.strip()] if not texts: return None if all(t == t.upper() for t in texts): logger.debug("all_caps_lines: subtitle is entirely uppercase — rule skipped") return None min_caps = max(int(opts.get("all_caps_min_length", 4)), 1) return re.compile(rf"^(?=.*[A-ZÀ-Ž&+]{{{min_caps},}})[A-ZÀ-Ž\-_\s&+]+$", re.UNICODE) def _apply_credit_removal(subs: pysubs2.SSAFile, options: dict) -> list[Change]: from credit_remover import _is_credit_line if not subs.events: return [] total_end_ms = max(e.end for e in subs.events) changes: list[Change] = [] to_remove: list[int] = [] for i, event in enumerate(subs.events): if event.is_comment: continue text = event.plaintext.strip() if _is_credit_line(text, total_end_ms=total_end_ms, event_start_ms=event.start): ts = f"{_ms_to_str(event.start)} --> {_ms_to_str(event.end)}" changes.append(Change(i, ts, text, "", "credit_removal")) to_remove.append(i) for i in sorted(to_remove, reverse=True): del subs.events[i] return changes def resolve_config(global_cfg: dict, series_cfg: dict | None) -> dict: """Merge series-level override on top of global config. series_cfg values: None or missing key → use global value True / False → override global value """ result = dict(global_cfg) for key, value in (series_cfg or {}).items(): if value is not None: result[key] = value return result