#!/usr/bin/env python3 """Consolidate English subtitles in a media file: pick the best text track, ffsubsync it, clean it, and remux it as the only English subtitle (a real Forced track is kept; a file keeping both Japanese and English audio, if anime, also gets an "English (dub)" subtitle). Usage: consolidate-subs.py | --scan [--jobs N] Exit codes: 0 ok/skip, 1 hard fail. """ from __future__ import annotations import argparse, hashlib, json, os, re, shutil, signal, subprocess, sys, tempfile, time from typing import NamedTuple from concurrent.futures import ProcessPoolExecutor, as_completed from contextlib import nullcontext from datetime import datetime, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from media_stack.paths import ( # noqa: E402 VAR_DIR, VAR_LOG, VAR_QUARANTINE_SUBTITLES, VAR_STATE, ensure_var_dirs, ) ensure_var_dirs() from media_stack.config import ( # noqa: E402 ASS_CODECS, IMAGE_CODECS, PIPELINE_VERSION, NORMALIZE_TMP_DIR, SUBLIMINAL_MIN_COVERAGE, TEXT_CODECS, TMP_MKV_RE, ) from media_stack.lang import is_english_tag # noqa: E402 from media_stack.probe import ( # noqa: E402 already_processed, duration_shortfall, file_key, parse_duration, probe, real_video_streams, ) from media_stack.fileid import ( # noqa: E402 Identity, file_identity, identity_from_stat, identity_matches, ) from media_stack.state import load_state, mutate_state, update_state_entry # noqa: E402 from media_stack.audio import ( # noqa: E402 confident_default_audio, get_audio_lang_pref, is_anime, is_dual_audio, select_keep_audio, ) from media_stack.arrlang import original_language_for # noqa: E402 from media_stack.subtitles import ( # noqa: E402 ass_to_srt, clean_srt_text, count_subtitle_lines, extract_track, decode_subtitle_bytes, decode_subtitle_text, fetch_via_subliminal, find_dub_sidecars, find_sidecar_subs, has_dub_subtitle, is_dub_track, is_forced_track, is_generated_sidecar, is_generated_track, is_sdh_track, run_ffsubsync, score_text_track, strip_hi_annotations, ) from media_stack.sublang import classify_candidate # noqa: E402 from media_stack.mux import ( # noqa: E402 ENG_DUB_TRACK_NAME, ENG_GENERATED_TRACK_NAME, ENG_TRACK_NAME, add_dub_subtitle, remux, ) from media_stack.loudness import normalize_budget_s # noqa: E402 from media_stack.locking import acquire_file_lock # noqa: E402 from media_stack.tags import set_consolidated_tag # noqa: E402 from media_stack import sublang, subverify # noqa: E402 from media_stack.subverify import ( # noqa: E402 UPGRADE_MIN_PASSING_WINDOWS, generated_track_windows, judge_sidecar_against_windows, ) from media_stack.sweeps import sweep_orphans as _sweep_orphans # noqa: E402 STATE_FILE = VAR_STATE / "consolidate-subs.state.json" LOG_FILE = VAR_LOG / "consolidate-subs.log" QUARANTINE_LOG = VAR_LOG / "sub-quarantine.jsonl" # Separate from STATE_FILE: that cache is keyed on (size, mtime, v), which a landing # sidecar changes neither of, so it would skip the file forever. Read by sub-fallback.py. NEEDS_ENGLISH_STATE_FILE = VAR_STATE / "needs-english.json" def sweep_orphans(root: Path, dry_run: bool = False) -> dict: return _sweep_orphans(root, script_basename=Path(__file__).name, dry_run=dry_run) def _log_sweep_result(res: dict, dry: bool) -> None: prefix = "SWEEP-DRY" if dry else "SWEEP" for path, pid in res["deleted_files"]: log(f"{prefix}: tmp file (pid={pid}): {path}") for path in res["deleted_dirs"]: log(f"{prefix}: workdir: {path}") for kind, path, info in res["skipped"]: log(f"{prefix}-SKIP {kind}: {path} ({info})") log(f"{prefix} summary: {len(res['deleted_files'])} files, {len(res['deleted_dirs'])} dirs, {len(res['skipped'])} skipped") def log(msg: str) -> None: line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}" print(line, flush=True) try: with LOG_FILE.open("a") as fh: fh.write(line + "\n") except Exception: pass def _utc_now_iso() -> str: """Microsecond precision: whole seconds would defeat sub-fallback's snapshot-vs-current check.""" return datetime.now(timezone.utc).isoformat(timespec="microseconds") def _needs_english_safe(op_name: str, path_for_log: Path, fn) -> None: """Swallow any failure: this bookkeeping must never turn a real result into FAIL.""" try: fn() except Exception as exc: log(f"WARNING: needs-english.json {op_name} failed for {path_for_log} " f"({type(exc).__name__}: {exc}); consolidate result unaffected") def _file_identity(path: Path) -> list[int] | None: """`[st_ino, st_size, st_mtime_ns]`. No ctime (unlike media_stack.fileid): this means "same release", and a hardlink change would needlessly reset the grace period.""" try: st = path.stat() except OSError: return None return [st.st_ino, st.st_size, st.st_mtime_ns] def _clear_needs_english(path: Path, dry_run: bool) -> None: """Drop `path`'s needs-english entry once FIXED.""" if dry_run: return key = str(path.resolve()) def _drop(state: dict) -> dict: state.pop(key, None) return state _needs_english_safe("clear", path, lambda: mutate_state(NEEDS_ENGLISH_STATE_FILE, _drop)) def _apply_cleanup_needs_english_update( path: Path, pre_identity: list[int] | None, result_path: Path, dry_run: bool, ) -> None: """Record NEEDS_BAZARR after the cleanup remux. `first_seen` resets only if the entry at `path` stores an identity other than `pre_identity` (a new release replaced the file); the entry moves to `result_path` (the suffix may change).""" if dry_run: return old_key = str(path.resolve()) new_key = str(result_path.resolve()) now = _utc_now_iso() current_identity = _file_identity(result_path) def _update(state: dict) -> dict: old_entry = state.pop(old_key, None) first_seen = None if isinstance(old_entry, dict): stored_identity = old_entry.get("identity") if stored_identity is None or stored_identity == pre_identity: first_seen = old_entry.get("first_seen") state[new_key] = { "first_seen": first_seen or now, "last_seen": now, "identity": current_identity, } return state _needs_english_safe( "cleanup-upsert", result_path, lambda: mutate_state(NEEDS_ENGLISH_STATE_FILE, _update)) def process_file(path_str: str, dry_run: bool = False) -> dict: """Returns {path, status: SKIP/FIXED/NEEDS_BAZARR/FAIL, detail}; never raises. dry_run mutates nothing (a would-be FIXED returns SKIP with detail prefix "dry-run:").""" try: return _process_file_inner(path_str, dry_run=dry_run) except Exception as e: return {"path": path_str, "status": "FAIL", "detail": f"unhandled: {type(e).__name__}: {str(e)[:120]}"} def _process_file_inner(path_str: str, dry_run: bool = False) -> dict: path = Path(path_str) if not path.is_file(): return {"path": path_str, "status": "FAIL", "detail": "not a file"} if TMP_MKV_RE.search(path.name): return {"path": path_str, "status": "SKIP", "detail": "tmp file (orphan candidate; run --sweep-only to clean)"} # Single-file entry points bypass walk()'s prune. Check BEFORE acquire_file_lock (it # creates the .lock file). Resolve first (a relative path inside the workdir would # slip past `parts`); on failure REFUSE, since the unverified spelling fails open. try: _resolved = path.resolve() except OSError as e: return {"path": path_str, "status": "FAIL", "detail": f"cannot resolve path: {e}"} if NORMALIZE_TMP_DIR in _resolved.parts: return {"path": path_str, "status": "SKIP", "detail": "normalize-audio workdir intermediate"} with acquire_file_lock(path) as acquired: if not acquired: return {"path": path_str, "status": "SKIP", "detail": "locked by other runner"} return _process_locked(path, path_str, dry_run=dry_run) def _kill_process_group(proc: subprocess.Popen, grace_s: float = 15.0) -> None: """Kill `proc`'s group (start_new_session=True, so pid == pgid): SIGTERM, `grace_s` for EVERY member (a grandchild may ignore it), then SIGKILL. The leader is reaped as soon as it exits (a zombie still counts as a member). Never raises.""" pgid = proc.pid try: os.killpg(pgid, signal.SIGTERM) deadline = time.monotonic() + grace_s while time.monotonic() < deadline: proc.poll() os.killpg(pgid, 0) # ProcessLookupError once the group is empty time.sleep(0.1) os.killpg(pgid, signal.SIGKILL) except OSError: pass # group gone (ProcessLookupError) or no longer signalable try: proc.communicate(timeout=15) except subprocess.TimeoutExpired: pass def _raise_on_sigterm(signum, frame) -> None: raise SystemExit(128 + signum) def _normalize_audio_inline(path: Path, locked_path: Path | None = None) -> None: """Run normalize-audio.py in a subprocess (its process pool and locks must not mix with ours). `locked_path` becomes NORMALIZE_INHERIT_LOCK_PATH so the child inherits our lock. Errors never propagate: consolidation is already committed. The cap is `normalize_budget_s(size)`; on expiry or interrupt the child's whole process group is killed, leaving no orphan ffmpeg.""" script = Path(__file__).parent / "normalize-audio.py" env = {**os.environ} if locked_path is not None: env["NORMALIZE_INHERIT_LOCK_PATH"] = str(locked_path) try: budget_s = normalize_budget_s(path.stat().st_size) except OSError: budget_s = 7200 try: proc = None try: # else SIGTERM to us leaves the child (own session) running previous = signal.signal(signal.SIGTERM, _raise_on_sigterm) except ValueError: # not the main thread previous = None try: proc = subprocess.Popen( [sys.executable, str(script), str(path)], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, start_new_session=True, ) stdout, stderr = proc.communicate(timeout=budget_s) except subprocess.TimeoutExpired: _kill_process_group(proc) log(f"audio-normalize TIMEOUT ({_hms(budget_s)} budget), process group killed: {path}") return except BaseException: if proc is not None: _kill_process_group(proc) raise finally: if previous is not None: signal.signal(signal.SIGTERM, previous) # rc=0 is not enough: a "locked" SKIP also exits 0 and is a no-op. stdout_tail = (stdout or "").strip().splitlines()[-1:] or [""] last = stdout_tail[0] if proc.returncode == 0 and "locked by other pipeline" in last: log(f"audio-normalize SKIPPED (lock conflict): {path} — {last[:160]}") elif proc.returncode == 0: log(f"audio-normalize OK: {path}") else: tail = (stderr or "").strip().splitlines()[-1:] or [""] log(f"audio-normalize FAIL rc={proc.returncode}: {path} -- {tail[0][:200]}") except Exception as e: log(f"audio-normalize ERR: {path} -- {type(e).__name__}: {e}") def _expose_tag_normalize(out: Path, path: Path, replacement: Path, path_str: str, tag: bool = True, normalize: bool = True) -> tuple[dict | None, bool]: """Swap the muxed `out` into `replacement`, stamp CONSOLIDATED_SUBS, optionally run inline normalization, all under the destination .mkv's lock (on .mp4 -> .mkv `path`'s lock does not cover the new file). The no-main-English cleanup passes `tag=False, normalize=False`: it must not mark the file consolidated, and normalize-audio could re-encode audio or let ffmpeg auto-flag a default, bypassing `confident_default_audio`. Returns `(early_result, tag_written)`; `early_result` is a SKIP (destination locked) or FAIL (sibling .mkv appeared, or `out` is shorter than the source) dict for the caller to return, else None. A failed tag is logged, not rolled back (state.json backstops idempotency). """ dest_lock = (acquire_file_lock(replacement) if replacement != path else nullcontext(True)) with dest_lock as dest_acquired: if replacement != path and not dest_acquired: out.unlink(missing_ok=True) return ({"path": path_str, "status": "SKIP", "detail": f"destination {replacement.name} locked by other runner"}, False) # Re-check under the lock (TOCTOU with _process_locked's check). if replacement != path and replacement.exists(): out.unlink(missing_ok=True) return ({"path": path_str, "status": "FAIL", "detail": f"collision: sibling .mkv appeared for {path.name}"}, False) # A short remux output once replaced full episodes: compare durations before the swap. problem = duration_shortfall(probe(path), probe(out), log) if problem: out.unlink(missing_ok=True) return ({"path": path_str, "status": "FAIL", "detail": f"duration verify: {problem}"}, False) # atomic same-filesystem rename: readers see the old file or the finished mux, never a partial one os.replace(out, replacement) if replacement != path and path.exists(): path.unlink() tag_written = False if tag: # An uncaught raise would skip the state update and sidecar cleanup. try: tag_written = set_consolidated_tag(replacement, PIPELINE_VERSION) except Exception as exc: log(f"ERROR: CONSOLIDATED_SUBS tag write raised for {replacement}: " f"{type(exc).__name__}: {exc} (consolidation done; tag missing)") else: if not tag_written: log(f"ERROR: CONSOLIDATED_SUBS tag write failed for {replacement} " f"(consolidation done + state recorded; MKV idempotency marker missing)") if normalize: _normalize_audio_inline(replacement, locked_path=replacement) return (None, tag_written) # "Positively foreign": base subtag is a 2-3 letter ASCII code that is not English, not # und/mul/mis/zxx, not in the qaa-qtz private-use range. Anything else is KEPT. # ASCII-only: str.isalpha() would accept e.g. "éé". _SUBTITLE_SPECIAL_LANG_CODES = frozenset({"und", "mul", "mis", "zxx"}) _SUBTITLE_LANG_BASE_RE = re.compile(r"[a-z]{2,3}") def _is_positively_foreign_sub(s: dict) -> bool: """The cleanup drops only these.""" lang = ((s.get("tags") or {}).get("language") or "").strip().lower() base = lang.split("-", 1)[0] if not _SUBTITLE_LANG_BASE_RE.fullmatch(base): return False if base in _SUBTITLE_SPECIAL_LANG_CODES: return False if "qaa" <= base <= "qtz": return False return not is_english_tag(lang) # Content gate: an English tag or filename is a claim, not proof, so `classify_candidate` # checks the text. A rejected EMBEDDED candidate is never chosen; a rejected SIDECAR is # moved to VAR_QUARANTINE_SUBTITLES, never deleted. _QUARANTINE_NAME_TRIES = 1000 class _Unclassifiable(OSError): """Could not classify (over the bound, changing while read, or not text). An OSError so candidate loops count it as an extraction failure; never used on its tag or name.""" class _ForcedGate(NamedTuple): state: str # "accepted" | "rejected" | "unreadable" text: "str | None" # cleaned SRT to embed info: dict def _gate_embedded_forced(path: Path, forced: dict) -> _ForcedGate: """Gate the embedded forced English track BEFORE any decision depends on it (a foreign track tagged English must not make the file "only forced English"); the gate sees the bytes before ASS/SSA conversion. - accepted: `text` is the cleaned SRT; None if conversion failed. - rejected: proven foreign; dropped. - unreadable: proves nothing; track is kept. """ idx = forced["index"] scratch = Path(tempfile.mkdtemp(prefix=f"consolsub_{os.getpid()}_forced_", dir=path.parent)) try: ext = ".ass" if forced.get("codec_name") in ASS_CODECS else ".srt" ftmp = _workfile(scratch, "forced", ext) if not extract_track(path, idx, forced.get("codec_name"), ftmp): log(f"embedded forced track {idx} in {path} could not be extracted; " f"not counted as forced English") return _ForcedGate("unreadable", None, {}) try: ok, info = _gate_local_file(ftmp) except _Unclassifiable as exc: log(f"content-gate SKIP embedded forced track {idx} in {path}: cannot be " f"classified ({exc}); not used as the forced track") return _ForcedGate("unreadable", None, {}) except OSError as exc: log(f"WARNING: cannot read embedded forced track {idx} in {path}: " f"{type(exc).__name__}: {exc}") return _ForcedGate("unreadable", None, {}) if not ok: log(f"content-gate REJECT embedded forced track {idx} in {path}: tagged English, " f"text scored {info.get('verdict')} (reason={info.get('reason')}) " f"-- proven foreign, not used as the forced track") return _ForcedGate("rejected", None, info) try: if ftmp.suffix == ".ass": fsrt = _workfile(scratch, "forced-srt", ".srt") if not ass_to_srt(ftmp, fsrt): log(f"embedded forced track {idx} in {path} passed the content gate but " f"could not be converted to SRT; not used as the forced track") return _ForcedGate("accepted", None, info) ftmp = fsrt return _ForcedGate( "accepted", clean_srt_text(ftmp.read_text(encoding="utf-8", errors="replace")), info) except OSError as exc: log(f"WARNING: embedded forced track {idx} in {path} could not be prepared: " f"{type(exc).__name__}: {exc}; not used as the forced track") return _ForcedGate("accepted", None, info) finally: shutil.rmtree(scratch, ignore_errors=True) def _snapshot_text(p: Path) -> "tuple[_Snapshot, str]": """Raises `_Unclassifiable` if over the size bound, changing while read, or not text.""" snap = _read_snapshot(p) if snap is None: raise _Unclassifiable(f"{p.name}: over the {UPGRADE_MAX_SIDECAR_BYTES}-byte bound " f"or changing while it was read") text = decode_subtitle_text(snap.data) if text is None: raise _Unclassifiable(f"{p.name}: not decodable as text") return snap, text def _gate_local_file(p: Path) -> tuple[bool, dict]: """Content gate for a scratch file: `(is_english, info)`. An accepted file is rewritten as DECODED UTF-8 so UTF-16/cp1252 never reaches clean, ffsubsync or mkvmerge. Raises `_Unclassifiable`.""" _snap, text = _snapshot_text(p) is_eng, info = classify_candidate(text) if is_eng: p.write_bytes(text.encode("utf-8")) return is_eng, info def _last_cue_start(srt: Path) -> float | None: """Start (s) of the latest cue in a UTF-8 SRT; None if none or unreadable.""" try: cues = subverify._sidecar_cues(srt.read_text(encoding="utf-8", errors="replace")) except OSError: return None return max((start for start, _ in cues), default=None) def _hms(seconds: float) -> str: s = int(seconds) return f"{s // 3600}:{s % 3600 // 60:02d}:{s % 60:02d}" def _late_sidecar_detail(base: str, foreign: int, redundant: int, dry_run: bool) -> str: verb = "dry-run: would quarantine" if dry_run else "quarantined" parts = [] if foreign: parts.append(f"{verb} {foreign} sidecar(s)") if redundant: parts.append(f"{verb} {redundant} redundant sidecar(s)") return "; ".join([base, *parts]) def _quarantine_stamp() -> str: """UTC timestamp without colons, safe in a filename.""" return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") def _log_quarantine(original: Path, dest: Path, verdict_info: dict, reason: str) -> None: """Append to QUARANTINE_LOG. Never raises: must not undo a completed move.""" rec = { "timestamp": _utc_now_iso(), "original_path": str(original), "quarantine_path": str(dest), "verdict": verdict_info.get("verdict"), "scores": verdict_info, "reason": reason, } try: QUARANTINE_LOG.parent.mkdir(parents=True, exist_ok=True) with QUARANTINE_LOG.open("a") as fh: fh.write(json.dumps(rec) + "\n") except Exception as exc: log(f"WARNING: sub-quarantine.jsonl write failed for {dest}: " f"{type(exc).__name__}: {exc}") def _quarantine_fsync_chain(quarantine_dir: Path) -> list[Path]: """Directories to fsync before the original goes: the copy's and each ancestor up to `var/`, on EVERY move (finding a directory existing proves nothing about disk). A quarantine dir outside `VAR_DIR` is assumed to use `var/quarantine/subtitles`.""" top = VAR_DIR if VAR_DIR in quarantine_dir.parents else quarantine_dir.parent.parent chain = [quarantine_dir] p = quarantine_dir while p != top and p.parent != p: p = p.parent chain.append(p) return chain def _fsync_dir(path: Path) -> None: fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY) try: os.fsync(fd) finally: os.close(fd) def _quarantine_sidecar(sidecar: Path, snap: _Snapshot, verdict_info: dict, reason: str) -> bool: """Move a content-rejected sidecar into VAR_QUARANTINE_SUBTITLES instead of deleting it (`classify_candidate` is a lexical heuristic). The copy is written from `snap.data` (the bytes classified, no re-read) with O_CREAT|O_EXCL (new suffix on collision; an existing file is never touched), fsynced and size-checked, then the directory chain is fsynced, and only then is the original unlinked, and only if it still has `snap.identity` (a sidecar replaced meanwhile by Bazarr or sub-fallback.py survives and our copy is removed). Nothing is renamed: the library and `var/` are different filesystems. Any failure is logged, removes our partial copy, keeps the original and never raises. Returns True iff the sidecar was moved (a failed jsonl write still returns True). """ dest: Path | None = None try: VAR_QUARANTINE_SUBTITLES.mkdir(parents=True, exist_ok=True) stamp = _quarantine_stamp() fd = -1 for n in range(_QUARANTINE_NAME_TRIES): name = f"{stamp}-{sidecar.name}" if n == 0 else f"{stamp}-{n}-{sidecar.name}" candidate = VAR_QUARANTINE_SUBTITLES / name try: fd = os.open(candidate, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o660) except FileExistsError: continue dest = candidate break if dest is None: raise OSError(f"no free quarantine name after {_QUARANTINE_NAME_TRIES} tries") with os.fdopen(fd, "wb") as fh: fh.write(snap.data) fh.flush() os.fsync(fh.fileno()) written = os.fstat(fh.fileno()).st_size if written != len(snap.data): raise OSError(f"quarantine copy is {written} bytes, expected {len(snap.data)}") for d in _quarantine_fsync_chain(VAR_QUARANTINE_SUBTITLES): _fsync_dir(d) now = file_identity(sidecar) if now is None: raise OSError("sidecar vanished before it could be removed") if now != snap.identity: raise OSError("sidecar changed after it was classified " "(replaced or modified); left in place") sidecar.unlink() except Exception as exc: log(f"WARNING: quarantine failed for {sidecar}: {type(exc).__name__}: {exc}; " f"original left in place") if dest is not None: try: dest.unlink(missing_ok=True) except Exception: pass return False _log_quarantine(sidecar, dest, verdict_info, reason) return True def _gate_forced_sidecars(sidecars: list, accepted: dict, unclassified: "list[Path] | None" = None) -> list: """FULL-path content gate for FORCED English-named sidecars (a bogus one would block `sub-fallback.py` and show in Jellyfin). A foreign one is quarantined and dropped from the returned list (also when the quarantine failed). An accepted one (short forced files score TOO-LITTLE-TEXT, so only clearly foreign ones are rejected) is recorded in `accepted` as `path -> snapshot`; FIXED may embed it and only then counts it consumed. An unclassifiable one is left on disk and appended to `unclassified` for the caller to report as an extraction failure.""" kept = [] for entry in sidecars: sc_path, is_forced = entry[0], entry[1] if not is_forced: kept.append(entry) continue try: snap = _read_snapshot(sc_path) except OSError as exc: log(f"WARNING: cannot read forced sidecar {sc_path}: {type(exc).__name__}: {exc}; " f"left in place, not counted as forced English") if unclassified is not None: unclassified.append(sc_path) continue text = None if snap is None else decode_subtitle_text(snap.data) if snap is None or text is None: log(f"content-gate SKIP forced sidecar {sc_path}: cannot be classified " f"({'over the size bound or changing while it was read' if snap is None else 'not decodable as text'}); " f"left in place, not counted as forced English") if unclassified is not None: unclassified.append(sc_path) continue is_eng, verdict_info = classify_candidate(text) if is_eng: accepted[sc_path] = snap kept.append(entry) continue log(f"content-gate REJECT forced sidecar {sc_path}: named English, text scored " f"{verdict_info.get('verdict')} (reason={verdict_info.get('reason')}) -- quarantining") # Out of every decision whether or not the move succeeds. if not _quarantine_sidecar(sc_path, snap, verdict_info, "forced sidecar rejected during consolidation"): log(f"content-gate: rejected forced sidecar {sc_path} could not be quarantined; " f"left on disk, not counted as forced English") return kept _REDUNDANT_REASON = "redundant: embedded English subtitle present" _LATE_FOREIGN_REASON = "late sidecar beside already-processed file" def _embedded_english_text_tracks(info: dict | None) -> list[dict]: """Embedded TEXT subtitle streams tagged English, except dub ones (they follow the dub audio and never stand in for a main track).""" return [s for s in (info or {}).get("streams", []) if s.get("codec_type") == "subtitle" and s.get("codec_name") in TEXT_CODECS and is_english_tag((s.get("tags") or {}).get("language")) and not is_dub_track(s)] def _eligible_embedded(tracks: list[dict], is_forced: bool) -> list[dict]: """A MAIN sidecar is retired only by a non-forced track, a FORCED one only by a forced track (a main track does not cover signs and foreign speech).""" return [t for t in tracks if is_forced_track(t) == is_forced] def _late_english_sidecar_verdict(media: Path, sc_path: Path, is_forced: bool, tracks: list[dict]) -> str: """"redundant" (quarantine), "keep" (the file's only English subtitle), or "upgrade" (the only embedded English is a generated Whisper transcript: left to the generated -> real upgrade check). Tracks are by TAG; the caller verifies content.""" eligible = _eligible_embedded(tracks, is_forced) if not eligible: return "keep" # Whisper never makes a forced track, so "upgrade" is main-only. if (not is_forced and not is_generated_sidecar(media, sc_path) and all(is_generated_track(t) for t in eligible)): return "upgrade" return "redundant" _MAX_EMBEDDED_CONTENT_CHECKS = 3 # (start_s, duration_s): ~10 minutes from 5 minutes in (skips intros), else from the start. _EMBEDDED_SAMPLE_WINDOWS = ((300.0, 600.0), (0.0, 600.0)) def _embedded_track_is_english(path: Path, track: dict, forced: bool = False) -> bool: """Does an embedded track tagged English POSITIVELY look like English? Must score `eng` (the content gate's benefit of the doubt is not enough: this retires a sidecar); a FORCED track may also be too short to score. Unreadable or empty is False (the caller keeps the sidecar).""" idx = track.get("index") text = None for start, dur in _EMBEDDED_SAMPLE_WINDOWS: try: text = subverify.embedded_text_window( path, idx, track.get("codec_name") or "", start, dur) except Exception as exc: log(f"WARNING: cannot sample embedded subtitle {idx} of {path}: " f"{type(exc).__name__}: {exc}") return False if text and text.strip(): break if not text or not text.strip(): log(f"embedded subtitle {idx} of {path} could not be read for verification") return False verdict = sublang.score(text)["verdict"] if verdict == "eng" or (forced and verdict == "TOO-LITTLE-TEXT"): return True log(f"embedded subtitle {idx} of {path} is tagged English but its text is not positive " f"English evidence (language check: {verdict})") return False def _upgrade_rejection_reasons(path: Path, snaps: dict[Path, _Snapshot | None], sidecars: list, record: object) -> dict[str, str] | None: """The upgrade check's decision on the non-generated main English sidecars, from its rejection `record`. None if no record still holds for THIS video and THESE sidecars; else `{name: reason}` for those judged wrong (possibly empty: a sidecar with no verdict must never be given up). A verdict holds per sidecar while its fingerprint and the video's identity match. A valid record means generated-only, so no probe is needed.""" video_identity = file_identity(path) if video_identity is None: return None real = [sc for sc, forced, _sdh in sidecars if not forced and not is_generated_sidecar(path, sc)] known = {sc: snaps[sc] for sc in real if snaps.get(sc) is not None} verdicts = _recorded_verdicts(record, video_identity, known) if verdicts is None: return None return {name: why for name, why in verdicts.items() if why} _UNSET: object = object() # no baseline taken (None: tried and failed) def _quarantine_foreign_sidecars(path: Path, dry_run: bool, info: dict | None = None, upgrade_rejection: object = None, video_identity: object = _UNSET) -> tuple[int, int]: """Late-sidecar sweep for an ALREADY-PROCESSED video, run after `_generated_upgrade_check` declined to upgrade (a late sidecar changes neither the cache key nor the tag, so it would otherwise sit there forever). Quarantines, never deletes: - sidecars that score non-English ("foreign"); - English sidecars made REDUNDANT by an embedded English text subtitle whose TEXT (a ~10-minute sample, not just its tag) is verified English; - a non-generated sidecar beside a generated-only file once the upgrade check REJECTED it for this exact video and sidecar (`upgrade_rejection`), reason "rejected upgrade: ". Without that rejection it is kept and the check retries. An English sidecar is kept when the video has no embedded English text subtitle, the probe fails, or the embedded text is non-English or unreadable. Returns `(foreign, redundant)` counts (what WOULD be, in `dry_run`). `video_identity` (taken before the caller probed, else here) is re-checked before EACH move: on change the sweep stops, keeping everything not yet moved. If the caller's baseline could not be read (None) nothing is moved. """ try: sidecars = find_sidecar_subs(path) except OSError as exc: log(f"WARNING: late-sidecar scan failed for {path}: {type(exc).__name__}: {exc}") return 0, 0 if video_identity is _UNSET: video_identity = file_identity(path) if video_identity is None: log(f"WARNING: late-sidecar sweep of {path}: the video's identity could not be read " f"before it was probed; keeping everything this call") return 0, 0 snaps: dict[Path, _Snapshot | None] = {} for sc_path, _forced, _sdh in sidecars: try: snaps[sc_path] = _read_snapshot(sc_path) except OSError as exc: log(f"WARNING: cannot read sidecar {sc_path} for late-sidecar " f"classification: {type(exc).__name__}: {exc}") snaps[sc_path] = None continue if snaps[sc_path] is None: log(f"WARNING: sidecar {sc_path} too large or changing; left unclassified") tracks: list[dict] | None = None # embedded English text tracks (None: unknown) probed = False def _embedded_tracks() -> list[dict] | None: nonlocal tracks, probed if not probed: probed = True data = info if info is not None else probe(path) if data is None: log(f"WARNING: cannot probe {path} to judge late English sidecars " f"redundant; keeping them") else: tracks = _embedded_english_text_tracks(data) return tracks rejection = _upgrade_rejection_reasons(path, snaps, sidecars, upgrade_rejection) content_ok: dict = {} # (embedded stream index, sidecar is forced) -> positive English foreign = redundant = 0 for sc_path, is_forced, _is_sdh in sidecars: snap = snaps[sc_path] if snap is None: continue text = decode_subtitle_text(snap.data) if text is None: log(f"WARNING: sidecar {sc_path} is not decodable as text; left unclassified") continue is_eng, verdict_info = classify_candidate(text) if is_eng and rejection is not None and not is_forced \ and not is_generated_sidecar(path, sc_path): # A sidecar the upgrade check REJECTED is quarantined; no verdict: kept, retried. why = rejection.get(sc_path.name) if why is None: log(f"kept: possible upgrade for generated subtitle: {sc_path}") continue reason, is_redundant = f"rejected upgrade: {why}", True elif is_eng: embedded = _embedded_tracks() if embedded is None: continue verdict = _late_english_sidecar_verdict(path, sc_path, is_forced, embedded) if verdict == "keep": continue if verdict == "upgrade": log(f"kept: possible upgrade for generated subtitle: {sc_path}") continue # CONTENT, not just the tag: a French track tagged `eng` must not cost the file # its only real subtitle. candidates = sorted(_eligible_embedded(embedded, is_forced), key=is_generated_track) verified = False for track in candidates[:_MAX_EMBEDDED_CONTENT_CHECKS]: key_ = (track.get("index"), is_forced) if key_ not in content_ok: content_ok[key_] = _embedded_track_is_english(path, track, is_forced) if content_ok[key_]: verified = True break if not verified: log(f"kept: embedded English subtitle of {path} not verified as English; " f"keeping late sidecar {sc_path}") continue reason, is_redundant = _REDUNDANT_REASON, True else: reason, is_redundant = _LATE_FOREIGN_REASON, False if dry_run: log(f"DRY-RUN: would quarantine late sidecar {sc_path} " f"({reason if is_redundant else 'verdict=' + str(verdict_info.get('verdict'))})") ok = True else: if file_identity(path) != video_identity: log(f"WARNING: {path} changed while its late sidecars were being judged; " f"keeping the rest (the next scan starts over)") break ok = _quarantine_sidecar(sc_path, snap, verdict_info, reason) if ok: if is_redundant: redundant += 1 else: foreign += 1 return foreign, redundant def _late_sidecar_sweep(path: Path, dry_run: bool, info: dict | None, upgrade_rejection: object, video_identity: object = _UNSET) -> tuple[int, int]: """`_quarantine_foreign_sidecars` that never changes the SKIP result it decorates.""" try: return _quarantine_foreign_sidecars(path, dry_run, info, upgrade_rejection, video_identity) except Exception as exc: log(f"WARNING: late-sidecar sweep failed for {path}: {type(exc).__name__}: {exc}") return 0, 0 def _cleanup_no_main_english( path: Path, path_str: str, status: str, base_detail: str, sub_streams: list[dict], audio_streams: list[dict], video_streams: list[dict], forced_text: dict | None, eng_image: list[dict], dry_run: bool, proven_foreign_sub_indices: frozenset[int] = frozenset(), ) -> dict: """On NEEDS_BAZARR, record the file in the needs-english tracker for `sub-fallback.py`. `pre_identity` is captured BEFORE the impl so a new release still resets `first_seen` while the impl's own remux does not.""" pre_identity = _file_identity(path) result = _cleanup_no_main_english_impl( path, path_str, status, base_detail, sub_streams, audio_streams, video_streams, forced_text, eng_image, dry_run, proven_foreign_sub_indices, ) if result.get("status") == "NEEDS_BAZARR": _apply_cleanup_needs_english_update(path, pre_identity, Path(result["path"]), dry_run) return result def _cleanup_no_main_english_impl( path: Path, path_str: str, status: str, base_detail: str, sub_streams: list[dict], audio_streams: list[dict], video_streams: list[dict], forced_text: dict | None, eng_image: list[dict], dry_run: bool, proven_foreign_sub_indices: frozenset[int] = frozenset(), ) -> dict: """Cleanup remux for a file with no main English subtitle yet (called from the four `_process_locked` NEEDS_BAZARR / FAIL branches), so a MULTI release does not keep every foreign subtitle and a foreign default audio for weeks while Bazarr works. Never delete a track that might be wanted and never silently change a flag: a wrong default is recoverable, a deleted track is not. - Video is not touched (cover-art removal is the full path's job). - Audio is never dropped or reordered; only the `default` flag moves, and only when `confident_default_audio` is confident (never a bare `default`, never a guess). `None` leaves audio as is, so an Arr outage cannot re-remux back and forth. - Subtitles: only a POSITIVELY foreign track (`_is_positively_foreign_sub`) or one proven foreign by the content gate is dropped; ambiguous ones, the forced-English text track and every English image subtitle (`eng_image`) are kept. - Every kept track gets explicit flags from the SOURCE dispositions (`cleanup_flags`); the confident audio pick is the only deliberate change. - No loudness normalization (it can re-encode audio and ffmpeg auto-flags a default). Does NOT stamp CONSOLIDATED_SUBS or touch sidecars: the file still gets the full pass once an English subtitle appears. The output is re-probed before the swap: track counts and the default-flag pattern must match the intent, else FAIL with the original untouched. Returns the caller's `status` (unless remux/verify failed); `detail` gains a cleanup summary. """ original_language = original_language_for(path) confident_idx = confident_default_audio( path, audio_streams, original_language=original_language) default_flagged = { s["index"] for s in audio_streams if (s.get("disposition") or {}).get("default") } audio_default_change = confident_idx is not None and default_flagged != {confident_idx} keep_video_idx = [s["index"] for s in video_streams] keep_audio_idx = [s["index"] for s in audio_streams] keep_sub_indices = {s["index"] for s in eng_image} if forced_text is not None: keep_sub_indices.add(forced_text["index"]) # forced_text/eng_image are never dropped: they are not gated. for s in sub_streams: if s["index"] in keep_sub_indices: continue if s["index"] in proven_foreign_sub_indices or _is_positively_foreign_sub(s): continue keep_sub_indices.add(s["index"]) kept_sub_streams = [s for s in sub_streams if s["index"] in keep_sub_indices] dropped_subs = sum(1 for s in sub_streams if s["index"] not in keep_sub_indices) something_to_clean = audio_default_change or dropped_subs > 0 if not something_to_clean: return {"path": path_str, "status": status, "detail": f"{base_detail}; nothing to clean"} if dry_run: return {"path": path_str, "status": status, "detail": (f"{base_detail}; dry-run: would clean " f"audio_default_change={audio_default_change} " f"dropped_subs={dropped_subs}")} cleanup_flags: dict[int, dict[str, bool]] = {} for s in video_streams: cleanup_flags[s["index"]] = {"default": bool((s.get("disposition") or {}).get("default"))} for s in audio_streams: is_default = (s["index"] == confident_idx) if audio_default_change \ else bool((s.get("disposition") or {}).get("default")) cleanup_flags[s["index"]] = {"default": is_default} for s in kept_sub_streams: disp = s.get("disposition") or {} cleanup_flags[s["index"]] = { "default": bool(disp.get("default")), "forced": bool(disp.get("forced")), } out = path.with_suffix(path.suffix + f".consol.{os.getpid()}.tmp.mkv") out.unlink(missing_ok=True) ok, detail = remux( path, out, keep_video_idx, keep_audio_idx, None, None, keep_subtitle_indices=sorted(keep_sub_indices), cleanup_flags=cleanup_flags, ) if not ok: out.unlink(missing_ok=True) return {"path": path_str, "status": "FAIL", "detail": f"cleanup remux: {detail}"} # Verify BEFORE the swap; never touch the original on a mismatch. verify_info = probe(out) if verify_info is None: out.unlink() return {"path": path_str, "status": "FAIL", "detail": "cleanup verify: probe failed"} verify_streams = verify_info.get("streams", []) out_video_streams = [s for s in verify_streams if s.get("codec_type") == "video"] out_audio_streams = [s for s in verify_streams if s.get("codec_type") == "audio"] out_subs = sum(1 for s in verify_streams if s.get("codec_type") == "subtitle") expected_audio = len(audio_streams) expected_subs = len(keep_sub_indices) expected_video = len(video_streams) if (len(out_audio_streams) != expected_audio or out_subs != expected_subs or len(out_video_streams) != expected_video): out.unlink() return {"path": path_str, "status": "FAIL", "detail": (f"cleanup verify: expected audio={expected_audio} " f"subs={expected_subs} video={expected_video}, got " f"audio={len(out_audio_streams)} subs={out_subs} " f"video={len(out_video_streams)}")} # Streams of each type keep their relative order: compare by position. intended_video_defaults = [cleanup_flags[i]["default"] for i in keep_video_idx] intended_audio_defaults = [cleanup_flags[i]["default"] for i in keep_audio_idx] out_video_defaults = [bool((s.get("disposition") or {}).get("default")) for s in out_video_streams] out_audio_defaults = [bool((s.get("disposition") or {}).get("default")) for s in out_audio_streams] if out_video_defaults != intended_video_defaults or out_audio_defaults != intended_audio_defaults: out.unlink() return {"path": path_str, "status": "FAIL", "detail": (f"cleanup verify: default-flag mismatch -- " f"video expected={intended_video_defaults} got={out_video_defaults}, " f"audio expected={intended_audio_defaults} got={out_audio_defaults}")} replacement = path.with_suffix(".mkv") if replacement != path and replacement.exists(): out.unlink(missing_ok=True) return {"path": path_str, "status": "FAIL", "detail": f"collision: sibling .mkv exists for {path.name}"} # tag=False: stamping would stop this branch from ever running again. early_result, _tag_written = _expose_tag_normalize( out, path, replacement, path_str, tag=False, normalize=False) if early_result is not None: return early_result return {"path": str(replacement), "status": status, "detail": (f"{base_detail}; cleaned: audio_default_change={audio_default_change} " f"dropped_subs={dropped_subs}")} def _workfile(workdir: Path, role: str, suffix: str) -> Path: """Every workdir temp file comes from here, never from a name derived from the video or a sidecar (a video called `forced` with a `forced.srt` sidecar would collide).""" fd, name = tempfile.mkstemp(prefix=f"{role}-", suffix=suffix, dir=workdir) os.close(fd) return Path(name) def _sidecar_as_srt(sc_path: Path, workdir: Path, data: bytes | None = None) -> Path | None: """Copy sidecar `sc_path` (or write `data`) into `workdir`, converted to SRT if `.ass`/`.ssa`/`.vtt`. None if conversion failed.""" local = _workfile(workdir, "sidecar", sc_path.suffix) if data is None: shutil.copy2(sc_path, local) else: local.write_bytes(data) suffix = local.suffix.lower() if suffix in (".ass", ".ssa"): srt_p = _workfile(workdir, "sidecar-srt", ".srt") if not ass_to_srt(local, srt_p): return None local = srt_p elif suffix == ".vtt": srt_p = _workfile(workdir, "sidecar-srt", ".srt") r = subprocess.run(["ffmpeg", "-y", "-v", "error", "-i", str(local), str(srt_p)], capture_output=True, timeout=60) if r.returncode != 0: return None local = srt_p return local class _SubCandidate(NamedTuple): score: int path: Path source: str # "embedded" | "sidecar" | "subliminal" generated: bool # Whisper output (".generated" sidecar / "(generated)" title) sdh: bool # hearing-impaired / SDH / CC variant @property def rank(self) -> tuple[bool, bool, int]: """Higher wins: real beats generated; within a class non-SDH beats SDH (used only when it is the sole candidate in its class); score only breaks ties.""" return (not self.generated, not self.sdh, self.score) def _embedded_candidate(path: Path, s: dict, workdir: Path) -> tuple[_SubCandidate | None, str]: """Extract embedded text track `s`, content-gate it and convert it to SRT. Returns `(candidate, outcome)`; outcome is "ok", "failed" (could not be extracted, read or converted) or "rejected" (the content gate proved it foreign).""" ext = ".ass" if s.get("codec_name") in ASS_CODECS else ".srt" tmp = _workfile(workdir, f"embedded{s['index']}", ext) if not extract_track(path, s["index"], s.get("codec_name"), tmp): return None, "failed" # Gate the extracted file before any ASS/SSA conversion. try: is_eng, verdict_info = _gate_local_file(tmp) except OSError as exc: if isinstance(exc, _Unclassifiable): log(f"content-gate SKIP embedded track {s['index']} in {path}: cannot be " f"classified ({exc}); not a candidate") return None, "failed" if not is_eng: log(f"content-gate REJECT embedded track {s['index']} in {path}: " f"tagged English, text scored {verdict_info.get('verdict')} " f"(reason={verdict_info.get('reason')}) -- treated as foreign") return None, "rejected" if tmp.suffix == ".ass": srt_p = _workfile(workdir, f"embedded{s['index']}-srt", ".srt") if not ass_to_srt(tmp, srt_p): return None, "failed" tmp = srt_p lc = count_subtitle_lines(tmp) return (_SubCandidate(score_text_track(s, lc), tmp, "embedded", is_generated_track(s), is_sdh_track(s)), "ok") # "English (dub)": the subtitle that matches the English DUB audio of a file that keeps both # Japanese and English audio (the main track translates the Japanese). Never ffsubsync'd: a # release's CC track is timed to its release and a Whisper one to the dub audio itself. def _finish_dub(srt: Path) -> bool: """Clean the UTF-8 SRT `srt` in place and strip its hearing-impaired annotations; False (file unchanged) if no cue is left.""" try: text = strip_hi_annotations( clean_srt_text(srt.read_text(encoding="utf-8", errors="replace"))) if "-->" not in text: return False srt.write_text(text, encoding="utf-8") except OSError: return False return True def _dub_from_sidecar(sc_path: Path, workdir: Path) -> tuple[Path, Identity] | None: """Read the `.en.dub.srt` `sc_path` ONCE, content-gate it and return its cleaned SRT working copy with the identity it was read at. A foreign one is quarantined; one that cannot be read or converted, or has no dialogue left, stays in place. None then.""" try: snap, text = _snapshot_text(sc_path) is_eng, verdict_info = classify_candidate(text) if not is_eng: log(f"content-gate REJECT dub sidecar {sc_path}: named English, text scored " f"{verdict_info.get('verdict')} (reason={verdict_info.get('reason')}) " f"-- quarantining") _quarantine_sidecar(sc_path, snap, verdict_info, "dub sidecar rejected during consolidation") return None local = _sidecar_as_srt(sc_path, workdir, data=text.encode("utf-8")) except (OSError, subprocess.SubprocessError) as exc: # _Unclassifiable is an OSError log(f"dub sidecar {sc_path} left in place: {exc}") return None if local is None or not _finish_dub(local): log(f"dub sidecar {sc_path} left in place: could not be converted to SRT " f"or has no dialogue") return None return local, snap.identity def _dub_srt(workdir: Path, sidecars: list[Path], embedded: list[_SubCandidate], consumed: dict[Path, Identity]) -> Path | None: """The cleaned working SRT for the dub track, else None: the first usable `.en.dub.srt` (recorded in `consumed`), otherwise the best-scoring dub-like embedded candidate.""" for sc_path in sidecars: got = _dub_from_sidecar(sc_path, workdir) if got is not None: consumed[sc_path] = got[1] return got[0] best = max(embedded, key=lambda c: c.score, default=None) return best.path if best is not None and _finish_dub(best.path) else None def _verify_added_dub(out: Path, src_info: dict) -> str | None: """Re-probe `out` of a dub add; the first mismatch with the source plus one new subtitle, else None: video, audio and the existing subtitles unchanged in count and default/forced flags, the CONSOLIDATED_SUBS tag kept, and the new "English (dub)" subtitle last (English, neither default nor forced).""" info = probe(out) if not info: return "probe of the remuxed file failed" def of(i: dict, kind: str) -> list[dict]: return [s for s in i.get("streams", []) if s.get("codec_type") == kind] def flags(ss: list[dict]) -> list[tuple[bool, bool]]: return [(bool((s.get("disposition") or {}).get("default")), bool((s.get("disposition") or {}).get("forced"))) for s in ss] for kind in ("video", "audio"): if flags(of(info, kind)) != flags(of(src_info, kind)): return f"{kind} streams or their flags changed" out_subs, src_subs = of(info, "subtitle"), of(src_info, "subtitle") if len(out_subs) != len(src_subs) + 1 or flags(out_subs[:-1]) != flags(src_subs): return "the existing subtitles changed" new = out_subs[-1] if (not is_english_tag((new.get("tags") or {}).get("language")) or (new.get("tags") or {}).get("title") != ENG_DUB_TRACK_NAME or flags([new]) != [(False, False)]): return "the new subtitle is not an English dub track that is neither default nor forced" if already_processed(src_info) and not already_processed(info): return "the CONSOLIDATED_SUBS tag was lost" return None def _add_late_dub(path: Path, path_str: str, dry_run: bool, info: dict | None, prev: dict | None, video_identity: Identity | None) -> dict | None: """An ALREADY-PROCESSED anime file that keeps both Japanese and English audio and has no dub subtitle yet takes a `.en.dub.srt` as a new "English (dub)" track: a plain mkvmerge copy of the file plus that track, nothing else reprocessed. Verified before the swap, state updated, the consumed sidecar removed. Returns the result, or None when there is nothing to do (no dub sidecar, not dual anime, a dub track already there, the sidecar unusable) so the late-sidecar sweep goes on. No playback gate: like every swap here it is an atomic `os.replace`, and a session playing the old file keeps its open inode.""" sidecars = find_dub_sidecars(path) if not sidecars or path.suffix.lower() != ".mkv": return None if info is None: info = probe(path) if not info: return None if not (is_dual_audio([s for s in info.get("streams", []) if s.get("codec_type") == "audio"]) and is_anime(path)): return None if has_dub_subtitle(info): log(f"dub sidecar {sidecars[0]} left in place: {path.name} already has a dub subtitle") return None if dry_run: return {"path": path_str, "status": "SKIP", "detail": f"dry-run: would add dub subtitle ({sidecars[0].name})"} workdir = Path(tempfile.mkdtemp(prefix=f"consolsub_{os.getpid()}_", dir=path.parent)) out = path.with_suffix(path.suffix + f".consol.{os.getpid()}.tmp.mkv") try: got = None for sc_path in sidecars: got = _dub_from_sidecar(sc_path, workdir) if got is not None: break if got is None: return None dub_srt, sc_ident = got out.unlink(missing_ok=True) ok, detail = add_dub_subtitle(path, out, dub_srt) if not ok: return {"path": path_str, "status": "FAIL", "detail": f"dub remux: {detail}"} problem = _verify_added_dub(out, info) if problem: return {"path": path_str, "status": "FAIL", "detail": f"dub verify: {problem}"} if file_identity(path) != video_identity: log(f"{path.name} changed while its dub subtitle was added; original kept") return {"path": path_str, "status": "SKIP", "detail": "dub: the video changed, original kept"} early_result, _tag_written = _expose_tag_normalize( out, path, path, path_str, tag=False, normalize=False) if early_result is not None: return early_result if file_identity(sc_path) == sc_ident: sc_path.unlink(missing_ok=True) else: log(f"WARNING: dub sidecar {sc_path} changed since it was read; left in place") size, mtime = file_key(path) update_state_entry(STATE_FILE, str(path.resolve()), {**(prev or {"status": "skip-tag"}), "size": size, "mtime": mtime, "v": PIPELINE_VERSION}) return {"path": path_str, "status": "FIXED", "detail": f"dub subtitle added from {sc_path.name}"} finally: out.unlink(missing_ok=True) shutil.rmtree(workdir, ignore_errors=True) # Generated -> real subtitle upgrade. A file consolidated with only a Whisper transcript # ("English (generated)") promises a real subtitle will replace it. Runs under the per-file # lock BEFORE the state-cache / tag-present skips. A sidecar is used only once proven the # right episode's English subtitle: English text whose cues match the generated track in # >= 2 of 3 bounded windows (`judge_sidecar_against_windows`). UPGRADE_REJECTED_KEY = "upgrade_rejected" # A sidecar is read ONCE; anything bigger is refused, never judged on a prefix. UPGRADE_MAX_SIDECAR_BYTES = 8 * 1024 * 1024 class _Snapshot(NamedTuple): """One sidecar, read once: what was verified is exactly these bytes.""" identity: Identity # fstat on the same handle, unchanged across the read digest: str # sha256 of `data` data: bytes class _Verified(NamedTuple): srt: bytes # the verified working SRT bytes sdh: bool # ranking only identity: Identity digest: str # sha256 of the sidecar as read (before conversion) class _Upgrade(NamedTuple): # The full pass muxes these exact bytes, then deletes the one it muxed if still exactly # what was read, and nothing else. sidecars: dict[Path, _Verified] # Re-checked before the remux and the swap; any change aborts. video_identity: Identity _KEEPABLE_SUB_CODECS = TEXT_CODECS | IMAGE_CODECS def _upgrade_kept_tracks(sub_streams: list[dict]) -> list[dict]: """Every forced English subtitle track (text or image) and every English dub track; an upgrade carries each over.""" return [s for s in sub_streams if is_english_tag((s.get("tags") or {}).get("language")) and (is_forced_track(s) or is_dub_track(s))] def _read_snapshot(p: Path) -> _Snapshot | None: """Read `p` once (fstat and bytes from the same handle). None if over `UPGRADE_MAX_SIDECAR_BYTES` (never verify, mux or quarantine a prefix) or not read completely and stably. Raises OSError if unreadable (`_snapshot_sidecar` maps that to None).""" limit = UPGRADE_MAX_SIDECAR_BYTES with p.open("rb") as fh: before = os.fstat(fh.fileno()) if before.st_size > limit: return None data = fh.read(limit + 1) # may have grown since fstat after = os.fstat(fh.fileno()) if len(data) > limit: return None # Complete and stable; a file being written or truncated is retried next scan. if len(data) != before.st_size: return None if identity_from_stat(before)[1:] != identity_from_stat(after)[1:]: return None return _Snapshot(identity_from_stat(before), hashlib.sha256(data).hexdigest(), data) def _snapshot_sidecar(p: Path) -> _Snapshot | None: """`_read_snapshot`, with unreadable also None (retried next scan).""" try: return _read_snapshot(p) except OSError: return None def _sidecar_fingerprint(snaps: dict[Path, _Snapshot]) -> list[list]: """`[name, size, sha256]` per sidecar, for matching a recorded rejection. The hash catches a same-size rewrite that size and mtime miss.""" return [[p.name, s.identity[1], s.digest] for p, s in sorted(snaps.items(), key=lambda kv: kv[0].name)] def _rejection_record(video_identity: Identity, fingerprint: list[list], reasons: dict[str, str] | None = None) -> dict: """Fingerprint of each sidecar with a NON-TRANSIENT outcome (a verdict, or undecided) plus the video identity; a transiently failed sidecar is left out so it stays retryable. `reasons` ({name: why}) is quoted when the late sweep quarantines a rejected sidecar; a record without it is the older format and is rejected.""" return {"video": list(video_identity), "sidecars": fingerprint, "reasons": dict(reasons or {})} def _recorded_verdicts(record: object, video_identity: Identity, snaps: dict[Path, "_Snapshot"]) -> dict[str, str | None] | None: """What a rejection `record` still says PER SIDECAR. None if it does not apply to this video (old format or identity differs); else `{name: reason or None}` for sidecars whose fingerprint still matches (reason = wrong-episode verdict, None = judged with no verdict). A changed or unrecorded sidecar is absent and verified again.""" if not isinstance(record, dict) or not isinstance(record.get("reasons"), dict): return None if not identity_matches(record.get("video"), video_identity, allow_legacy=False): return None entries = record.get("sidecars") recorded = {e[0]: e for e in entries if isinstance(e, list) and len(e) == 3 and isinstance(e[0], str)} if isinstance(entries, list) else {} reasons = record["reasons"] out: dict[str, str | None] = {} for sc, snap in snaps.items(): if recorded.get(sc.name) == [sc.name, snap.identity[1], snap.digest]: why = reasons.get(sc.name) out[sc.name] = str(why) if why else None return out def _record_upgrade_rejected(key: str, prev: dict, rejection: dict) -> None: """Best effort: lets the next scan skip the window reads.""" try: update_state_entry(STATE_FILE, key, {**prev, UPGRADE_REJECTED_KEY: rejection}) except Exception as exc: log(f"UPGRADE-SKIP: could not record rejection for {key}: " f"{type(exc).__name__}: {exc}") def _generated_upgrade_check(path: Path, cur: tuple[int, int], prev: dict | None ) -> tuple[_Upgrade | None, dict | None, dict | None]: """Should `path` (locked by the caller) have its generated English subtitle replaced by a real sidecar? Returns `(upgrade, info, rejected)`. `upgrade` is None unless: a MAIN non-generated English sidecar exists; the file is already consolidated (tag, or a `fixed` state entry for the current size/mtime, since the tag write can fail); every main English text track is "(generated)"; and a sidecar passes the English-text and same-episode checks. Each sidecar is read once and those exact bytes go to the full pass. `info` is the ffprobe result if this probed. `rejected` is the record to remember when verification judged the sidecars wrong, never for anything possibly transient. """ real = [(p, sdh) for (p, forced, sdh) in find_sidecar_subs(path) if not forced and not is_generated_sidecar(path, p)] if not real: return None, None, None notes: list[str] = [] reasons: dict[str, str] = {} # name -> why REJECTED (a verdict, not a failure) judged: dict[Path, _Snapshot] = {} snaps: dict[Path, _Snapshot] = {} for sc, _sdh in real: snap = _snapshot_sidecar(sc) if snap is None: notes.append(f"{sc.name}: unreadable or larger than " f"{UPGRADE_MAX_SIDECAR_BYTES} bytes") continue snaps[sc] = snap if not snaps: log(f"UPGRADE-SKIP: {path.name}: {'; '.join(notes)}") return None, None, None video_identity = file_identity(path) # before the probe if video_identity is None: return None, None, None remembered = _recorded_verdicts((prev or {}).get(UPGRADE_REJECTED_KEY), video_identity, snaps) or {} if len(snaps) == len(real) and all(sc.name in remembered for sc in snaps): return None, None, None info = probe(path) consolidated = bool(info) and ( already_processed(info) or (bool(prev) and prev.get("size") == cur[0] and prev.get("mtime") == cur[1] and prev.get("v") == PIPELINE_VERSION and prev.get("status") == "fixed")) if not info or not consolidated: return None, info, None gen_tracks = [s for s in info.get("streams", []) if s.get("codec_type") == "subtitle" and is_english_tag((s.get("tags") or {}).get("language")) and s.get("codec_name") in TEXT_CODECS and not is_forced_track(s) and not is_dub_track(s)] if not gen_tracks or not all(is_generated_track(s) for s in gen_tracks): return None, info, None gen = gen_tracks[0] subs_src = [s for s in info.get("streams", []) if s.get("codec_type") == "subtitle"] unkeepable = [s for s in _upgrade_kept_tracks(subs_src) if s.get("codec_name") not in _KEEPABLE_SUB_CODECS] if unkeepable: # The upgrade rebuilds subtitles, so it must carry every forced and dub track over. log(f"UPGRADE-SKIP: {path.name}: forced or dub English subtitle track(s) it cannot keep " f"(codec {', '.join(str(s.get('codec_name')) for s in unkeepable)}); " f"keeping the generated subtitle") return None, info, None try: duration = float((info.get("format") or {}).get("duration")) except (TypeError, ValueError): duration = 0.0 sdh_of = dict(real) verified: dict[Path, _Verified] = {} windows = None with tempfile.TemporaryDirectory(prefix="consolsub-upgrade-") as td: for sc, snap in snaps.items(): if sc.name in remembered: judged[sc] = snap if remembered[sc.name]: reasons[sc.name] = str(remembered[sc.name]) continue decoded = decode_subtitle_text(snap.data) if decoded is None: notes.append(f"{sc.name}: not decodable as text") judged[sc] = snap continue try: srt = _sidecar_as_srt(sc, Path(td), decoded.encode("utf-8")) srt_bytes = srt.read_bytes() if srt is not None else None except (OSError, subprocess.SubprocessError) as exc: notes.append(f"{sc.name}: could not be converted ({type(exc).__name__})") continue # transient: retried next scan if srt_bytes is None: notes.append(f"{sc.name}: could not be converted to SRT") continue sc_text = clean_srt_text(srt_bytes.decode("utf-8", errors="replace")) verdict = sublang.score(sc_text)["verdict"] if verdict != "eng": notes.append(f"{sc.name}: not usable English text (language check: {verdict})") # No reason: this test is stricter than the content gate ("la la la" or a # short unlisted-language file fail it), so it does not prove "foreign". judged[sc] = snap continue if windows is None: windows = generated_track_windows(path, gen, duration) if windows is None: notes.append("could not read the generated track's cues from the file") break # transient and video-wide if not windows: notes.append("the file's duration is missing or zero, so no verification " "windows can be placed") break ok, why, usable, evidence = judge_sidecar_against_windows(windows, sc_text) if ok: verified[sc] = _Verified(srt_bytes, sdh_of[sc], snap.identity, snap.digest) else: notes.append(f"{sc.name}: not this file's subtitle ({why})") judged[sc] = snap if evidence >= UPGRADE_MIN_PASSING_WINDOWS: # A verdict needs enough text on BOTH sides in >= 2 windows; a partial # sidecar is "not enough evidence" and the late sweep must not act on it. reasons[sc.name] = f"not this file's subtitle ({why})" if not verified: log(f"UPGRADE-SKIP: {path.name}: {'; '.join(notes)}") return None, info, (_rejection_record(video_identity, _sidecar_fingerprint(judged), reasons) if judged else None) return _Upgrade(verified, video_identity), info, None def _upgrade_track_flags(video_streams: list[dict], audio_streams: list[dict], kept_subs: list[dict]) -> dict[int, dict[str, bool]]: """Explicit default AND forced flag from the source dispositions (else mkvmerge picks).""" flags: dict[int, dict[str, bool]] = {} for s in [*video_streams, *audio_streams, *kept_subs]: disp = s.get("disposition") or {} flags[s["index"]] = {"default": bool(disp.get("default")), "forced": bool(disp.get("forced"))} return flags def _verify_upgrade_output(out: Path, video_streams: list[dict], audio_streams: list[dict], kept_subs: list[dict], flags: dict[int, dict[str, bool]] ) -> str | None: """Re-probe `out`; the first mismatch with what an upgrade must produce, else None: every source video/audio stream in order with its flags; then every kept forced English or dub track (source codec, English tag, flags); then the new main English subtitle (English, default, not forced).""" info = probe(out) if not info: return "probe of the remuxed file failed" streams = info.get("streams", []) out_v = [s for s in streams if s.get("codec_type") == "video"] out_a = [s for s in streams if s.get("codec_type") == "audio"] out_s = [s for s in streams if s.get("codec_type") == "subtitle"] expect_subs = len(kept_subs) + 1 if (len(out_v), len(out_a), len(out_s)) != (len(video_streams), len(audio_streams), expect_subs): return (f"expected video={len(video_streams)} audio={len(audio_streams)} " f"subs={expect_subs}, got video={len(out_v)} audio={len(out_a)} " f"subs={len(out_s)}") def _flags(ss: list[dict]) -> list[tuple[bool, bool]]: return [(bool((s.get("disposition") or {}).get("default")), bool((s.get("disposition") or {}).get("forced"))) for s in ss] want_v = [(flags[s["index"]]["default"], flags[s["index"]]["forced"]) for s in video_streams] want_a = [(flags[s["index"]]["default"], flags[s["index"]]["forced"]) for s in audio_streams] if _flags(out_v) != want_v or _flags(out_a) != want_a: return (f"default/forced flag mismatch -- video expected={want_v} got={_flags(out_v)}, " f"audio expected={want_a} got={_flags(out_a)}") def _english(s: dict) -> bool: return is_english_tag((s.get("tags") or {}).get("language")) got_subs = _flags(out_s) for pos, src in enumerate(kept_subs): got = out_s[pos] if got.get("codec_name") != src.get("codec_name"): return (f"kept subtitle #{pos + 1} changed codec: " f"{src.get('codec_name')} -> {got.get('codec_name')}") if not _english(got): return f"kept subtitle #{pos + 1} is not tagged English" want = (flags[src["index"]]["default"], flags[src["index"]]["forced"]) if got_subs[pos] != want: return (f"kept subtitle #{pos + 1} flags changed: default/forced " f"expected={want} got={got_subs[pos]}") main = out_s[-1] main_default, main_forced = got_subs[-1] if not _english(main): return "the new main subtitle is not tagged English" if not main_default or main_forced: return (f"the new main subtitle must be default and not forced, got " f"default={main_default} forced={main_forced}") return None def _verify_kept_source_subs(out: Path, n_video: int, n_audio: int, src: "dict | None", kept: dict[int, dict[str, bool]], dub: bool = False) -> str | None: """Re-probe `out` of a full pass that stream-copied an unreadable embedded forced English track (`kept`); the first mismatch, else None. Expects the source track (codec, English tag, exact flags), then the new main English subtitle (default, not forced), then with `dub` the "English (dub)" one (neither).""" if src is None: return "no source track to compare with" info = probe(out) if not info: return "probe of the remuxed file failed" streams = info.get("streams", []) out_v = [s for s in streams if s.get("codec_type") == "video"] out_a = [s for s in streams if s.get("codec_type") == "audio"] out_s = [s for s in streams if s.get("codec_type") == "subtitle"] n_subs = 3 if dub else 2 if (len(out_v), len(out_a), len(out_s)) != (n_video, n_audio, n_subs): return (f"expected video={n_video} audio={n_audio} subs={n_subs}, got " f"video={len(out_v)} audio={len(out_a)} subs={len(out_s)}") def _flags(s: dict) -> tuple[bool, bool]: d = s.get("disposition") or {} return bool(d.get("default")), bool(d.get("forced")) first, main = out_s[:2] want = kept[src["index"]] if first.get("codec_name") != src.get("codec_name"): return (f"the kept forced track changed codec: {src.get('codec_name')} -> " f"{first.get('codec_name')}") if not is_english_tag((first.get("tags") or {}).get("language")): return "the kept forced track is not tagged English" if _flags(first) != (want["default"], want["forced"]): return (f"the kept forced track's flags changed: default/forced expected=" f"{(want['default'], want['forced'])} got={_flags(first)}") if not is_english_tag((main.get("tags") or {}).get("language")): return "the new main subtitle is not tagged English" if _flags(main) != (True, False): return f"the new main subtitle must be default and not forced, got {_flags(main)}" if dub and (not is_english_tag((out_s[2].get("tags") or {}).get("language")) or _flags(out_s[2]) != (False, False)): return "the dub subtitle must be English, neither default nor forced" return None def _upgrade_video_changed(path: Path, path_str: str, upgrade: _Upgrade, when: str, out: Path | None = None) -> dict | None: """The SKIP result that aborts the upgrade if the video is no longer the file the gate probed (temp output removed, retried next scan); else None.""" if file_identity(path) == upgrade.video_identity: return None if out is not None: out.unlink(missing_ok=True) log(f"UPGRADE-SKIP: {path.name}: the video changed {when}; keeping it as it is") return {"path": path_str, "status": "SKIP", "detail": f"upgrade: the video changed {when}, original kept"} def _process_locked(path: Path, path_str: str, dry_run: bool = False) -> dict: if not path.is_file(): return {"path": path_str, "status": "SKIP", "detail": "file gone after lock"} state = load_state(STATE_FILE) key = f"{path.resolve()}" cur = file_key(path) video_identity = file_identity(path) prev = state.get(key) # The upgrade check runs first, ahead of the two skips. If it upgrades, the late sweep # does NOT run (the full pass consumes the verified sidecars); otherwise the late sweep # runs on either skip path, with the check's rejection. try: upgrade, info, rejected = _generated_upgrade_check(path, cur, prev) except Exception as exc: log(f"UPGRADE-SKIP: {path.name}: check failed: {type(exc).__name__}: {exc}") upgrade, info, rejected = None, None, None if upgrade is not None: names = ", ".join(sorted(p.name for p in upgrade.sidecars)) if dry_run: return {"path": path_str, "status": "SKIP", "detail": f"dry-run: would UPGRADE generated -> real subtitle ({names})"} log(f"UPGRADE: generated -> real subtitle ({names})") else: remembered = rejected or (prev or {}).get(UPGRADE_REJECTED_KEY) if prev and prev.get("size") == cur[0] and prev.get("mtime") == cur[1] and prev.get("v") == PIPELINE_VERSION: if rejected and not dry_run: _record_upgrade_rejected(key, prev, rejected) added = _add_late_dub(path, path_str, dry_run, info, prev, video_identity) if added is not None: return added foreign, redundant = _late_sidecar_sweep(path, dry_run, info, remembered, video_identity) return {"path": path_str, "status": "SKIP", "detail": _late_sidecar_detail("state cache", foreign, redundant, dry_run)} if info is None: info = probe(path) if not info: return {"path": path_str, "status": "FAIL", "detail": "probe failed"} if already_processed(info): added = _add_late_dub(path, path_str, dry_run, info, None, video_identity) if added is not None: return added if not dry_run: entry = {"size": cur[0], "mtime": cur[1], "v": PIPELINE_VERSION, "status": "skip-tag"} if rejected: entry[UPGRADE_REJECTED_KEY] = rejected update_state_entry(STATE_FILE, key, entry) foreign, redundant = _late_sidecar_sweep(path, dry_run, info, remembered, video_identity) return {"path": path_str, "status": "SKIP", "detail": _late_sidecar_detail("tag present", foreign, redundant, dry_run)} assert info is not None streams = info.get("streams", []) sub_streams = [s for s in streams if s.get("codec_type") == "subtitle"] # No early return on empty sub_streams: sidecar SRTs are a valid source. An untagged # or "und" track is deliberately not a candidate. eng_streams = [s for s in sub_streams if is_english_tag((s.get("tags") or {}).get("language"))] eng_text = [s for s in eng_streams if s.get("codec_name") in TEXT_CODECS] eng_image = [s for s in eng_streams if s.get("codec_name") in IMAGE_CODECS] forced_text = next((s for s in eng_text if is_forced_track(s)), None) main_candidates = [s for s in eng_text if s is not forced_text] # The only sidecars FIXED may remove, and only while this identity still holds. consumed: dict[Path, Identity] = {} accepted_forced: dict[Path, _Snapshot] = {} sidecars: list = [] unclassified_forced: list[Path] = [] if upgrade is None: sidecars = find_sidecar_subs(path) if not dry_run: sidecars = _gate_forced_sidecars(sidecars, accepted_forced, unclassified_forced) sidecar_main = [(p, sdh, is_generated_sidecar(path, p)) for (p, forced, sdh) in sidecars if not forced] sidecar_forced = next(((p) for (p, forced, sdh) in sidecars if forced), None) else: # Exactly the verified snapshots; the generated embedded track is being replaced. sidecar_main = [(p, v.sdh, False) for p, v in upgrade.sidecars.items()] sidecar_forced = None main_candidates = [] eng_image = [] forced_text = None # Skipped in a dry run (no extraction) and an upgrade (forced tracks are kept). forced_gate: _ForcedGate | None = None rejected_forced_idx: int | None = None forced_track_unreadable = False unreadable_forced_stream: dict | None = None if forced_text is not None and upgrade is None and not dry_run: forced_gate = _gate_embedded_forced(path, forced_text) if forced_gate.state == "rejected": rejected_forced_idx = forced_text["index"] forced_text = None elif forced_gate.state == "unreadable": forced_track_unreadable = True unreadable_forced_stream = forced_text forced_text = None unclassified_parts = [] if unclassified_forced: unclassified_parts.append(f"{len(unclassified_forced)} forced sidecar(s) unreadable or not text") if forced_track_unreadable: unclassified_parts.append("embedded forced track unreadable or not text") unclassified_note = "; ".join(unclassified_parts) early_proven_foreign = (frozenset({rejected_forced_idx}) if rejected_forced_idx is not None else frozenset()) audio_streams = [s for s in streams if s.get("codec_type") == "audio"] video_streams_all = [s for s in streams if s.get("codec_type") == "video"] real_video = real_video_streams(streams) # The cleanup path gets the raw `video_streams_all`; the FULL path drops cover art and # keeps one real video stream. video_streams = real_video if len(video_streams) > 1: default_v = next( (s for s in video_streams if (s.get("disposition") or {}).get("default")), video_streams[0], ) video_streams = [default_v] keep_video_idx = [s["index"] for s in video_streams] dropped_video = max(0, len(video_streams_all) - len(video_streams)) if upgrade is not None: # An upgrade changes only the English subtitle. keep_video_idx = [s["index"] for s in video_streams_all] dropped_video = 0 # `original_language_for` (Radarr/Sonarr) detects foreign-original films without # trusting the Matroska `default` flag, which release groups routinely set on a DUB. # multi_keep: anime, per-title opt-ins and foreign-original films keep the original track # AND the English dub; the Korean keyword path keeps a single best track. upgrade_flags: dict[int, dict[str, bool]] | None = None kept_subs: list[dict] = [] if upgrade is None: allowed_langs, primary_lang, multi_keep = get_audio_lang_pref( path, audio_streams, original_language=original_language_for(path)) keep_audio_idx = select_keep_audio(audio_streams, allowed_langs, primary_lang, multi_keep) else: # An upgrade changes only the subtitle: audio, forced English and dub tracks stay # as they are, with their source flags (via remux's cleanup_flags). primary_lang = "unchanged" keep_audio_idx = [s["index"] for s in audio_streams] kept_subs = _upgrade_kept_tracks(sub_streams) upgrade_flags = _upgrade_track_flags(video_streams_all, audio_streams, kept_subs) dropped_audio = max(0, len(audio_streams) - len(keep_audio_idx)) # An ANIME file that keeps Japanese AND English audio gets a second English subtitle # matching the dub: a `.en.dub.srt`, else an embedded dub/CC/SDH track, which then stays # out of the main ranking (unless nothing else is a candidate: see below). An upgrade keeps # the dub track it already has. dual = (upgrade is None and is_dual_audio([s for s in audio_streams if s["index"] in keep_audio_idx]) and is_anime(path)) dub_sidecars = find_dub_sidecars(path) if upgrade is None else [] dub_idx = {s["index"] for s in main_candidates if is_dub_track(s)} if dual else set() if dub_sidecars and not dual: log(f"dub sidecar(s) {', '.join(p.name for p in dub_sidecars)} left in place: " f"{path.name} is not anime keeping both Japanese and English audio") if not main_candidates and not eng_image and not sidecar_main: if sidecar_forced or forced_text: return _cleanup_no_main_english( path, path_str, "FAIL", "only forced English" + (f" ({unclassified_note})" if unclassified_note else ""), sub_streams, audio_streams, video_streams_all, forced_text, eng_image, dry_run, early_proven_foreign, ) if unclassified_note: # A tooling problem, not "no English candidates" (sub-fallback would place a # main sidecar beside the unreadable forced one). return _cleanup_no_main_english( path, path_str, "FAIL", f"all extracts failed ({unclassified_note}" + ("; 1 rejected by content gate" if rejected_forced_idx is not None else "") + ")", sub_streams, audio_streams, video_streams_all, forced_text, eng_image, dry_run, early_proven_foreign, ) return _cleanup_no_main_english( path, path_str, "NEEDS_BAZARR", "no English candidates" + (" (rejected by content gate)" if rejected_forced_idx is not None else ""), sub_streams, audio_streams, video_streams_all, forced_text, eng_image, dry_run, early_proven_foreign, ) if dry_run: sources = [] if main_candidates: sources.append(f"embedded={len(main_candidates)}") if sidecar_main: sources.append(f"sidecar={len(sidecar_main)}") if eng_image: sources.append(f"image={len(eng_image)} (needs subliminal)") return {"path": path_str, "status": "SKIP", "detail": f"dry-run: would FIX ({', '.join(sources)})"} workdir = Path(tempfile.mkdtemp(prefix=f"consolsub_{os.getpid()}_", dir=path.parent)) try: chosen_srt: Path | None = None scored: list[_SubCandidate] = [] dub_cands: list[_SubCandidate] = [] # the dub-like embedded ones among `scored` # content_rejected: TEXT scored non-English despite an English tag/name. # extraction_failed: could not be extracted/read/converted (tooling, kept visible). # rejected_embedded: embedded tracks proven non-English (the cleanup drops them). content_rejected = 1 if rejected_forced_idx is not None else 0 extraction_failed = len(unclassified_forced) + (1 if forced_track_unreadable else 0) rejected_embedded: set[int] = ( {rejected_forced_idx} if rejected_forced_idx is not None else set()) for s in main_candidates: candidate, outcome = _embedded_candidate(path, s, workdir) if candidate is not None: scored.append(candidate) if s["index"] in dub_idx: dub_cands.append(candidate) elif outcome == "rejected": content_rejected += 1 rejected_embedded.add(s["index"]) else: extraction_failed += 1 consumed_from: dict[Path, Path] = {} # working file -> its sidecar (upgrade) for n, (sc_path, is_sdh, sc_generated) in enumerate(sidecar_main): sc_ident: Identity | None = None if upgrade is not None: # The verified bytes, not the file as it is now. try: local = _workfile(workdir, f"verified{n}", ".srt") local.write_bytes(upgrade.sidecars[sc_path].srt) except OSError: continue consumed_from[local] = sc_path else: # ONE read: classified, the working copy and (if rejected) quarantined. try: snap, text = _snapshot_text(sc_path) is_eng, verdict_info = classify_candidate(text) if not is_eng: content_rejected += 1 log(f"content-gate REJECT sidecar {sc_path}: named English, text " f"scored {verdict_info.get('verdict')} " f"(reason={verdict_info.get('reason')}) -- quarantining") _quarantine_sidecar(sc_path, snap, verdict_info, "sidecar candidate rejected during consolidation") continue local = _sidecar_as_srt(sc_path, workdir, data=text.encode("utf-8")) sc_ident = snap.identity except _Unclassifiable as exc: log(f"content-gate SKIP sidecar {sc_path}: cannot be classified ({exc}); " f"left in place, not a candidate") extraction_failed += 1 continue except OSError: extraction_failed += 1 continue if local is None: extraction_failed += 1 continue lc = count_subtitle_lines(local) sidecar_score = 1100 + min(lc, 2000) // 5 + 50 scored.append(_SubCandidate(sidecar_score, local, "sidecar", sc_generated, is_sdh)) if sc_ident is not None: consumed[sc_path] = sc_ident proven_foreign = frozenset(rejected_embedded) if not scored and upgrade is not None: return {"path": path_str, "status": "SKIP", "detail": "upgrade: no verified subtitle could be used, " "kept the generated subtitle"} fetched_rejected: dict | None = None fetched_coverage_pct: int | None = None fetch_read_failed = False if not scored and eng_image: fetched = fetch_via_subliminal(path, workdir) if fetched is not None: try: fetched_ok, fetched_info = _gate_local_file(fetched) except OSError as exc: fetch_read_failed = True extraction_failed += 1 log(f"WARNING: cannot read the subliminal download for {path}: " f"{type(exc).__name__}: {exc}") else: # A last cue well before the end means a partial subtitle (disc 1 of 2). # Skipped, never a rejection, if duration or cues are unknown. short: tuple[float, float] | None = None # (last cue start, duration) if fetched_ok: duration = parse_duration((info.get("format") or {}).get("duration")) last_cue = _last_cue_start(fetched) if duration is None or last_cue is None: log(f"coverage check SKIPPED for subliminal download for {path}: " f"{'duration unknown' if duration is None else 'no timed cues'}") elif last_cue < SUBLIMINAL_MIN_COVERAGE * duration: short = (last_cue, duration) if short is not None: fetched_coverage_pct = int(100 * short[0] / short[1]) content_rejected += 1 log(f"coverage REJECT subliminal download for {path}: last cue at " f"{_hms(short[0])} of {_hms(short[1])} ({fetched_coverage_pct}%)") elif fetched_ok: lc = count_subtitle_lines(fetched) # Below a sidecar (1100): less vetted. scored.append(_SubCandidate(1000 + min(lc, 2000) // 5, fetched, "subliminal", False, False)) else: fetched_rejected = fetched_info content_rejected += 1 log(f"content-gate REJECT subliminal download for {path}: " f"text scored {fetched_info.get('verdict')} " f"(reason={fetched_info.get('reason')})") if not scored: # A tooling failure stays visible as FAIL; NEEDS_BAZARR only when every # non-accepted candidate was content-rejected (or the fetch came back empty). if extraction_failed: why = [] if unclassified_note: why.append(unclassified_note) if fetch_read_failed: why.append("subliminal download unreadable") if content_rejected: why.append(f"{content_rejected} rejected by content gate") return _cleanup_no_main_english( path, path_str, "FAIL", "all extracts failed" + (f" ({'; '.join(why)})" if why else ""), sub_streams, audio_streams, video_streams_all, forced_text, eng_image, dry_run, proven_foreign, ) if eng_image: return _cleanup_no_main_english( path, path_str, "NEEDS_BAZARR", f"only image subs (subliminal result covers only {fetched_coverage_pct}% of the film)" if fetched_coverage_pct is not None else "only image subs (subliminal fetch failed)" if fetched_rejected is None else "only image subs (subliminal result rejected by content gate)", sub_streams, audio_streams, video_streams_all, forced_text, eng_image, dry_run, proven_foreign, ) if content_rejected: return _cleanup_no_main_english( path, path_str, "NEEDS_BAZARR", "no English candidates (rejected by content gate)", sub_streams, audio_streams, video_streams_all, forced_text, eng_image, dry_run, proven_foreign, ) return _cleanup_no_main_english( path, path_str, "FAIL", "all extracts failed", sub_streams, audio_streams, video_streams_all, forced_text, eng_image, dry_run, proven_foreign, ) # Dub-like candidates leave the main ranking only when another candidate can be the main; # a sole CC/SDH track stays the main, as before. mains = [c for c in scored if c not in dub_cands] if mains: scored = mains else: dub_cands = [] scored.sort(key=lambda c: c.rank, reverse=True) chosen = scored[0] chosen_srt, chosen_generated = chosen.path, chosen.generated forced_srt_final = None if forced_gate is not None and forced_gate.state == "accepted" and forced_gate.text is not None: ftmp = _workfile(workdir, "forced", ".srt") ftmp.write_text(forced_gate.text, encoding="utf-8") forced_srt_final = ftmp # No usable embedded forced track: embed an accepted forced SIDECAR rather than let # the full path discard it. Only a sidecar embedded here counts as consumed. Never # in an upgrade (it keeps the source's own forced tracks). if forced_srt_final is None and accepted_forced and upgrade is None: # Until one converts; only one is embedded. for fside in sorted(accepted_forced): fsnap = accepted_forced[fside] try: fsrt = _sidecar_as_srt( fside, workdir, data=decode_subtitle_bytes(fsnap.data).encode("utf-8")) if fsrt is None: log(f"WARNING: forced sidecar {fside} could not be converted to SRT; " f"left in place, trying the next one") continue fsrt.write_text(clean_srt_text(fsrt.read_text(encoding="utf-8", errors="replace")), encoding="utf-8") forced_srt_final = fsrt consumed[fside] = fsnap.identity break except Exception as exc: log(f"WARNING: forced sidecar {fside} could not be embedded: " f"{type(exc).__name__}: {exc}; left in place, trying the next one") dub_srt = _dub_srt(workdir, dub_sidecars, dub_cands, consumed) if dual else None try: txt = chosen_srt.read_text(encoding="utf-8", errors="replace") cleaned = clean_srt_text(txt) chosen_srt.write_text(cleaned, encoding="utf-8") except Exception as e: return {"path": path_str, "status": "FAIL", "detail": f"clean failed: {e}"} synced_srt = _workfile(workdir, "synced", ".srt") sync_ok = run_ffsubsync(path, chosen_srt, synced_srt) final_srt = synced_srt if sync_ok else chosen_srt out = path.with_suffix(path.suffix + f".consol.{os.getpid()}.tmp.mkv") out.unlink(missing_ok=True) if upgrade is not None: # ffsubsync ran since the gate probed: remux only if unchanged. changed = _upgrade_video_changed(path, path_str, upgrade, "before the remux") if changed: return changed # An unreadable forced track is not proven foreign: unless a readable one replaces # it, it is stream-copied unchanged. kept_source_subs: dict[int, dict[str, bool]] | None = None if (upgrade is None and unreadable_forced_stream is not None and forced_srt_final is None): disp = unreadable_forced_stream.get("disposition") or {} kept_source_subs = {unreadable_forced_stream["index"]: { "default": bool(disp.get("default")), "forced": bool(disp.get("forced"))}} log(f"keeping the unreadable embedded forced track " f"{unreadable_forced_stream['index']} of {path} unchanged") extra: dict = {} if kept_source_subs: extra["kept_source_subs"] = kept_source_subs if dub_srt: extra["dub_srt"] = dub_srt ok, detail = remux( path, out, keep_video_idx, keep_audio_idx, final_srt, forced_srt_final, keep_subtitle_indices=[s["index"] for s in kept_subs] or None, cleanup_flags=upgrade_flags, eng_track_name=ENG_GENERATED_TRACK_NAME if chosen_generated else ENG_TRACK_NAME, **extra) if not ok: out.unlink(missing_ok=True) return {"path": path_str, "status": "FAIL", "detail": f"remux: {detail}"} if kept_source_subs is not None: problem = _verify_kept_source_subs( out, len(keep_video_idx), len(keep_audio_idx), unreadable_forced_stream, kept_source_subs, dub=dub_srt is not None) if problem: out.unlink(missing_ok=True) return {"path": path_str, "status": "FAIL", "detail": f"forced track verify: {problem}"} if upgrade is not None: assert upgrade_flags is not None problem = _verify_upgrade_output( out, video_streams_all, audio_streams, kept_subs, upgrade_flags) if problem: out.unlink(missing_ok=True) return {"path": path_str, "status": "FAIL", "detail": f"upgrade verify: {problem}"} # Refuse to overwrite a sibling .mkv ("Movie.mp4" must not destroy "Movie.mkv"). replacement = path.with_suffix(".mkv") if replacement != path and replacement.exists(): out.unlink(missing_ok=True) return {"path": path_str, "status": "FAIL", "detail": f"collision: sibling .mkv exists for {path.name}"} if upgrade is not None: changed = _upgrade_video_changed(path, path_str, upgrade, "before the swap", out) if changed: return changed # An upgrade never normalizes: its audio and video must stay the verified source. early_result, tag_written = _expose_tag_normalize( out, path, replacement, path_str, normalize=upgrade is None) if early_result is not None: return early_result # Delete ONLY sidecars read and accepted as English (`consumed`), and only while the # file still has the identity it was read with (one replaced by Bazarr or # sub-fallback.py meanwhile stays). An upgrade deletes only the verified sidecar it # muxed, and only if identity AND content hash match. dropped_sidecars = 0 if upgrade is None: for sc_path, sc_ident in consumed.items(): if sc_path == replacement: continue now_ident = file_identity(sc_path) if now_ident is None: continue if now_ident != sc_ident: log(f"WARNING: sidecar {sc_path} changed since it was read; left in place") continue try: sc_path.unlink() dropped_sidecars += 1 except OSError: pass for sc_path, _is_forced, _is_sdh in sidecars: if sc_path not in consumed and sc_path.exists(): log(f"sidecar {sc_path} was not consumed by this run; left in place") else: muxed_from = consumed_from.get(chosen_srt) verified = upgrade.sidecars.get(muxed_from) if muxed_from is not None else None if muxed_from is not None and verified is not None and muxed_from.exists(): # Re-read and compare sha256: a same-length rewrite with the mtime restored # keeps size and mtime. Unreadable or oversized counts as changed. now = _snapshot_sidecar(muxed_from) if (now is None or now.identity != verified.identity or now.digest != verified.digest): log(f"UPGRADE: left {muxed_from.name} in place (changed after the check)") else: # ACCEPTED RACE: a replacement landing between the re-check and this # unlink is lost. No atomic compare-and-unlink exists (renameat2 # RENAME_NOREPLACE gives EINVAL on this mergerfs pool); microseconds, # the same window `_quarantine_sidecar` accepts. try: muxed_from.unlink() dropped_sidecars += 1 except OSError: pass new_key = f"{replacement.resolve()}" new_size, new_mtime = file_key(replacement) update_state_entry(STATE_FILE, new_key, {"size": new_size, "mtime": new_mtime, "v": PIPELINE_VERSION, "status": "fixed", "synced": sync_ok, "tag_written": tag_written, **({"upgraded": True} if upgrade else {})}) _clear_needs_english(replacement, dry_run) dropped_embedded = max(0, len(sub_streams) - ( 1 + (len(kept_subs) if upgrade else (1 if forced_srt_final else 0)) + (1 if kept_source_subs else 0) + (1 if dub_srt else 0))) return {"path": str(replacement), "status": "FIXED", "detail": (f"{'UPGRADE generated->real ' if upgrade else ''}" f"synced={sync_ok} dropped_subs={dropped_embedded} " f"dropped_audio={dropped_audio} " f"dropped_video={dropped_video} " f"dropped_sidecars={dropped_sidecars} " f"audio_lang={primary_lang}" f"{' dub_subtitle=1' if dub_srt else ''}")} finally: shutil.rmtree(workdir, ignore_errors=True) def walk(root: Path): # Prune `.normalize-tmp`: its scratch renders were treated as media and got lock files. for r, dirs, files in os.walk(root): dirs[:] = [d for d in dirs if d != NORMALIZE_TMP_DIR] for f in files: fl = f.lower() if not fl.endswith((".mkv", ".mp4")): continue if fl.endswith(".tmp.mkv"): continue yield Path(r) / f def main(): ap = argparse.ArgumentParser() ap.add_argument("target", nargs="?", help="file or directory") ap.add_argument("--scan", help="walk directory") ap.add_argument("--jobs", type=int, default=1) ap.add_argument("--limit", type=int, default=0, help="cap files in scan mode (0 = no cap)") ap.add_argument("--sweep-only", metavar="DIR", help="sweep orphan tmp files / workdirs under DIR and exit") ap.add_argument("--no-sweep", action="store_true", help="skip the orphan sweep that runs at the start of --scan") ap.add_argument("--dry-run", action="store_true", help="with --sweep-only or --scan: report what would be deleted, delete nothing") args = ap.parse_args() if args.sweep_only: log(f"sweep-only start: {args.sweep_only} (dry_run={args.dry_run})") res = sweep_orphans(Path(args.sweep_only), dry_run=args.dry_run) _log_sweep_result(res, dry=args.dry_run) return 0 if args.scan: if not args.no_sweep: log(f"sweep before scan: {args.scan} (dry_run={args.dry_run})") res = sweep_orphans(Path(args.scan), dry_run=args.dry_run) _log_sweep_result(res, dry=args.dry_run) files = list(walk(Path(args.scan))) if args.limit: files = files[: args.limit] log(f"scan start: {len(files)} files, jobs={args.jobs}, dry_run={args.dry_run}") if args.jobs <= 1: for f in files: r = process_file(str(f), dry_run=args.dry_run) log(f"{r['status']}: {Path(r['path']).name} | {r.get('detail','')}") else: with ProcessPoolExecutor(max_workers=args.jobs) as ex: futs = {ex.submit(process_file, str(f), args.dry_run): f for f in files} for fut in as_completed(futs): r = fut.result() log(f"{r['status']}: {Path(r['path']).name} | {r.get('detail','')}") log("scan complete") return 0 if args.target: r = process_file(args.target, dry_run=args.dry_run) log(f"{r['status']}: {Path(r['path']).name} | {r.get('detail','')}") return 0 if r["status"] in ("FIXED", "SKIP", "NEEDS_BAZARR") else 1 ap.print_help() return 2 if __name__ == "__main__": sys.exit(main())