"""Media-stream probing utilities: ffprobe / mediainfo + stream selection. Extracted from ass_utils.py. Covers anything that reads the container (audio/subtitle stream metadata) or writes a subtitle stream out again: - run_ffprobe / get_media_streams / _run_engine — probe backends - has_target_language_stream / has_target_language_audio - get_all_subtitle_streams / select_best_subtitle_stream - get_subtitle_stream_output_path / extract_subtitle_stream The ASS tag-manipulation helpers (classify_styles, extract_tags, restore_tags, fix_line_breaks) stay in ass_utils.py. """ import json import logging import os import re import subprocess from config import get_settings # 0.71.0 — SDH detection. Matches the common markers fansubs and studios # use in track titles. Language codes never trigger this; we only inspect # the `title` field (word-boundary) plus the ffprobe `hearing_impaired` # disposition flag. _SDH_TITLE_RE = re.compile(r"\b(sdh|cc|hi)\b", re.IGNORECASE) def is_sdh_stream(stream: dict) -> bool: """Return True if the given ffprobe subtitle stream looks like SDH/CC/HI. Detection sources (either is sufficient): - `tags.title` matches /\b(sdh|cc|hi)\b/i - `disposition.hearing_impaired` is truthy """ if not stream: return False tags = stream.get("tags") or {} title = tags.get("title") or "" if _SDH_TITLE_RE.search(title): return True disposition = stream.get("disposition") or {} return bool(disposition.get("hearing_impaired")) logger = logging.getLogger(__name__) class _ExtractionGateBusyError(RuntimeError): """Keep a busy media I/O gate from being mistaken for a copy failure.""" def has_target_language_stream(ffprobe_data, target_language=None, kind="full"): """Check if the file has an embedded target language subtitle stream. Args: ffprobe_data: dict from ffprobe JSON output target_language: Language code (e.g., "de"). If None, uses default from settings. kind: "full" (default) counts dialogue tracks only; "forced" counts forced and signs & songs tracks only. Returns: str or None: "ass" if target lang ASS found, "srt" if SRT found, None otherwise. ASS takes priority over SRT. """ if target_language is None: settings = get_settings() target_tags = settings.get_target_lang_tags() else: from config import _get_language_tags target_tags = _get_language_tags(target_language) from forced_detection import detect_subtitle_type target_ass = False target_srt = False for stream in ffprobe_data.get("streams", []): if stream.get("codec_type") != "subtitle": continue lang = stream.get("tags", {}).get("language", "").lower() if lang not in target_tags: continue # A forced or "Signs & Songs" track is not the dialogue subtitle: it # must not satisfy the language, or a signs-only track would stop the # search for the real one (and drop wanted rows since 1.14.3). is_full = detect_subtitle_type(stream_info=stream)[0] == "full" if is_full != (kind == "full"): continue codec = stream.get("codec_name", "").lower() if codec in ("ass", "ssa"): target_ass = True elif codec in ("subrip", "srt"): target_srt = True if target_ass: return "ass" if target_srt: return "srt" return None def get_all_subtitle_streams(ffprobe_data: dict, exclude_language: str | None = None) -> list[dict]: """Return all embedded subtitle streams as a list of {lang, format} dicts. Args: ffprobe_data: dict from get_media_streams / ffprobe JSON output. exclude_language: ISO-639-1 code to exclude (typically the target language, already tracked separately in existing_sub). None = return all. Returns: Deduplicated list of dicts with 'lang' (raw language tag) and 'format' ('ass' or 'srt'). Unknown codecs (dvd_subtitle, etc.) are skipped. """ exclude_tags: set[str] = set() if exclude_language: from config import _get_language_tags exclude_tags = _get_language_tags(exclude_language) seen: set[tuple] = set() result: list[dict] = [] for stream in ffprobe_data.get("streams", []): if stream.get("codec_type") != "subtitle": continue lang = stream.get("tags", {}).get("language", "").lower() if not lang: continue if lang in exclude_tags: continue codec = stream.get("codec_name", "").lower() if codec in ("ass", "ssa"): fmt = "ass" elif codec in ("subrip", "srt"): fmt = "srt" else: continue key = (lang, fmt) if key not in seen: seen.add(key) result.append({"lang": lang, "format": fmt}) return result def has_target_language_audio(ffprobe_data, target_language=None): """Check if the file has an audio track in the target language. Args: ffprobe_data: dict from ffprobe JSON output target_language: Language code (e.g., "de"). If None, uses default from settings. Returns: bool: True if a target language audio track is found. """ if target_language is None: settings = get_settings() target_tags = settings.get_target_lang_tags() else: from config import _get_language_tags target_tags = _get_language_tags(target_language) for stream in ffprobe_data.get("streams", []): if stream.get("codec_type") != "audio": continue lang = stream.get("tags", {}).get("language", "").lower() if lang in target_tags: return True return False def _source_candidate_tag_sets(settings) -> list[set[str]]: """Tag sets for every candidate source language, in preference order.""" from config_language_data import _get_language_tags from translator._helpers import source_language_candidates try: candidates = source_language_candidates(settings) except Exception: # noqa: BLE001 — a settings hiccup must not break stream selection candidates = [settings.source_language] if getattr(settings, "source_language", "") else [] return [set(_get_language_tags(code)) for code in candidates if code] def select_best_subtitle_stream(ffprobe_data, format_filter=None): """Select the best source language subtitle stream from ffprobe data. Priority (ASS preferred over SRT): 1. Source lang ASS with "Full" in title (not Signs/Songs) 2. First source lang ASS without "sign"/"song" 3. First source lang ASS 4. ASS without target lang tag, not "sign"/"song" 5. First source lang SRT (fallback) 6. First SRT without target lang tag 7. None Args: ffprobe_data: dict from ffprobe JSON output format_filter: "ass" to only search ASS, "srt" for only SRT, None for all Returns: dict with sub_index, format, language, title — or None """ settings = get_settings() source_tags = settings.get_source_lang_tags() target_tags = settings.get_target_lang_tags() # Every language a translation may start from, in preference order, as # tag sets. A track outside this list is never picked as a source: the # old "first SRT that is not the target" fallback returned the ARABIC # track of a Blu-ray remux and it went through the pipeline as German. candidate_tag_sets = _source_candidate_tag_sets(settings) candidate_tags = set().union(*candidate_tag_sets) if candidate_tag_sets else set() def _eligible(language: str) -> bool: # An untagged track is the norm on fansub MKVs and cannot be judged # by its tag; anything tagged must be a configured candidate. return not language or language == "und" or language in candidate_tags streams = ffprobe_data.get("streams", []) ass_streams = [] srt_streams = [] sub_index = 0 for stream in streams: if stream.get("codec_type") != "subtitle": continue codec = stream.get("codec_name", "").lower() title = stream.get("tags", {}).get("title", "").lower() language = stream.get("tags", {}).get("language", "").lower() info = { "sub_index": sub_index, "stream_index": stream.get("index"), "title": title, "language": language, } if codec in ("ass", "ssa") and format_filter != "srt": info["format"] = "ass" ass_streams.append(info) elif ( codec in ("subrip", "srt", "mov_text", "webvtt", "text", "microdvd") and format_filter != "ass" ): info["format"] = "srt" srt_streams.append(info) sub_index += 1 # --- ASS Priority --- if ass_streams: # P1: "Full" subtitle (not signs/songs only) in a candidate language for s in ass_streams: if ( _eligible(s["language"]) and "full" in s["title"] and "sign" not in s["title"] and "song" not in s["title"] ): logger.info("Selected stream %d: '%s' (Full ASS)", s["sub_index"], s["title"]) return s # P2: Source language, non-signs src = [s for s in ass_streams if s["language"] in source_tags] for s in src: if "sign" not in s["title"] and "song" not in s["title"]: logger.info( "Selected stream %d: '%s' (Source lang ASS, non-signs)", s["sub_index"], s["title"], ) return s # P3: Any source language ASS if src: logger.info( "Selected stream %d: '%s' (Source lang ASS)", src[0]["sub_index"], src[0]["title"] ) return src[0] # P4: Non-signs/songs/OP/ED ASS in a candidate source language, # candidates in configured order, untagged tracks last _SKIP_TITLE_PARTS = {"sign", "song", "op", "ed", "karaoke", "credit", "caption"} for tags in [*candidate_tag_sets, {"", "und"}]: for s in ass_streams: if s["language"] in tags and not any( part in s["title"] for part in _SKIP_TITLE_PARTS ): logger.info( "Selected stream %d: '%s' (non-signs ASS)", s["sub_index"], s["title"] ) return s # --- SRT Fallback --- if srt_streams: # P5: Source language SRT src_srt = [s for s in srt_streams if s["language"] in source_tags] if src_srt: logger.info( "Selected stream %d: '%s' (Source lang SRT fallback)", src_srt[0]["sub_index"], src_srt[0]["title"], ) return src_srt[0] # P6: SRT in a candidate source language, candidates in configured # order, untagged tracks last for tags in [*candidate_tag_sets, {"", "und"}]: for s in srt_streams: if s["language"] in tags and s["language"] not in target_tags: logger.info( "Selected stream %d: '%s' (SRT fallback)", s["sub_index"], s["title"] ) return s # P7: target-language SRT as last resort (e.g. German dub in MP4) if srt_streams: tgt_srt = [s for s in srt_streams if s["language"] in target_tags] if tgt_srt: logger.info( "Selected stream %d: '%s' (Target lang SRT last resort)", tgt_srt[0]["sub_index"], tgt_srt[0]["title"], ) return tgt_srt[0] # Last resort: any ASS stream in a candidate language (or untagged) for s in ass_streams: if _eligible(s["language"]): logger.warning("No ideal stream, using first eligible ASS: %s", s["title"]) return s return None def run_ffprobe(file_path, use_cache=True): """Run ffprobe and return parsed JSON data for audio and subtitle streams. Uses cache if available and file hasn't changed (mtime check). Args: file_path: Path to the video file use_cache: If True, check cache first and store result Returns: dict: Parsed ffprobe JSON data with {"streams": [...]} containing both audio and subtitle streams (codec_type "audio" / "subtitle"). Raises: RuntimeError: If ffprobe fails, times out, or returns invalid JSON """ from db.cache import get_ffprobe_cache, set_ffprobe_cache # Check cache if enabled if use_cache: try: mtime = os.path.getmtime(file_path) cached = get_ffprobe_cache(file_path, mtime) if cached: logger.debug("Using cached ffprobe data for %s", file_path) return cached except (OSError, Exception) as e: logger.debug("Cache check failed for %s: %s", file_path, e) # Run ffprobe — no -select_streams filter so both audio and subtitle streams # are returned. Consumers filter by codec_type themselves. Previously this used # -select_streams s which caused has_target_language_audio() to never find # audio streams (bug fix). # # Audit Gemini-2026-05-09 R3: wrap ``file_path`` with ``_safe_arg_path`` # so a leading ``-`` in the basename can't be interpreted as a flag by # ffprobe. Same defence ``extract_subtitle_stream`` already applied. # Loglevel is ``error``, not ``quiet``: ``quiet`` suppresses stderr entirely, # so the RuntimeError below interpolated an empty string and every failure # logged as a bare "ffprobe failed:" with no reason (prod 2026-08-01, where # the real cause — files deleted from disk — stayed invisible). ``error`` # stays silent on success and only speaks up when something actually broke. from remux import _safe_arg_path cmd = [ "ffprobe", "-v", "error", "-print_format", "json", "-show_streams", _safe_arg_path(file_path), ] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) except subprocess.TimeoutExpired: raise RuntimeError(f"ffprobe timed out after 30s: {file_path}") if result.returncode != 0: raise RuntimeError(f"ffprobe failed: {result.stderr}") try: probe_data = json.loads(result.stdout) except json.JSONDecodeError as e: raise RuntimeError(f"ffprobe returned invalid JSON: {e}") # Cache the result if use_cache: try: mtime = os.path.getmtime(file_path) set_ffprobe_cache(file_path, mtime, probe_data) except (OSError, Exception) as e: logger.debug("Cache store failed for %s: %s", file_path, e) return probe_data def get_media_streams(file_path, use_cache=True): """Unified entry point for media stream metadata — engine-agnostic. Reads scan_metadata_engine from config and routes to ffprobe or mediainfo. Both engines return the same normalized {"streams": [...]} format, so the cache is engine-agnostic: an entry written by one engine is valid for another. Args: file_path: Path to the video file use_cache: If True, check cache before probing and store result after Returns: dict: {"streams": [...]} with audio and subtitle stream objects. Raises: RuntimeError: On probe failure (engine-specific errors propagate). FileNotFoundError: If engine=mediainfo and mediainfo is not installed. """ from db.cache import get_ffprobe_cache, set_ffprobe_cache settings = get_settings() engine = getattr(settings, "scan_metadata_engine", "auto") # Cache check — shared across all engines (same format, engine-agnostic) if use_cache: try: mtime = os.path.getmtime(file_path) cached = get_ffprobe_cache(file_path, mtime) if cached: logger.debug("Cache hit for %s (engine=%s)", file_path, engine) return cached except (OSError, Exception) as e: logger.debug("Cache check failed for %s: %s", file_path, e) probe_data = _run_engine(file_path, engine) if use_cache: try: mtime = os.path.getmtime(file_path) set_ffprobe_cache(file_path, mtime, probe_data) except (OSError, Exception) as e: logger.debug("Cache store failed for %s: %s", file_path, e) return probe_data def _run_engine(file_path, engine): """Dispatch to the configured metadata engine with fallback logic for 'auto'.""" from mediainfo_utils import _is_mediainfo_available, run_mediainfo if engine == "mediainfo": return run_mediainfo(file_path) if engine == "auto": if _is_mediainfo_available(): try: return run_mediainfo(file_path) except Exception as e: logger.warning("MediaInfo failed for %s, falling back to ffprobe: %s", file_path, e) return run_ffprobe(file_path, use_cache=False) # engine == "ffprobe" or unknown return run_ffprobe(file_path, use_cache=False) def get_subtitle_stream_output_path(media_path: str, stream_info: dict) -> str: """Build sidecar output path: {basename}.{lang}.{format} next to media file. The container tag is ISO 639-2/B ("ger", "eng"); the name uses the canonical ISO 639-1 code the rest of Sublarr works in. Writing the raw tag grew a second German subtitle next to every existing ``.de`` one, and the caller's "already extracted" guard — which keys off this very path — never recognised the sidecar already sitting there. An unknown tag is passed through unchanged rather than guessed at. Uses 'und' (undetermined) if language tag is missing. """ from config_language_data import normalize_language_code lang = normalize_language_code((stream_info.get("language") or "und").lower()) fmt = stream_info.get("format", "ass") base = os.path.splitext(media_path)[0] return f"{base}.{lang}.{fmt}" def get_legacy_subtitle_stream_output_path(media_path: str, stream_info: dict) -> str: """The name this sidecar had before the language code was canonicalised. Installs that ran the older code have their sidecars under the raw Matroska tag (``.ger.srt``). Callers check this path too, so canonicalising the name does not make Sublarr blind to a file it wrote itself and extract the same track a second time. """ lang = (stream_info.get("language") or "und").lower() fmt = stream_info.get("format", "ass") base = os.path.splitext(media_path)[0] return f"{base}.{lang}.{fmt}" def extract_subtitle_stream(mkv_path, stream_info, output_path): """Extract a subtitle stream (ASS or SRT) from an MKV file atomically. Audit G5 + G12: previously passed ``output_path`` straight into ``ffmpeg -y`` which writes incrementally to the destination. A crash, timeout, or ENOSPC mid-write left a partial sidecar at the final location that downstream code would treat as a complete file. The extraction now writes to a same-directory tempfile and only ``os.replace``s into place once ffmpeg exits zero. The temp file is unlinked on every error path so partial bytes never appear under the real name. Args: mkv_path: Path to the MKV file stream_info: dict from select_best_subtitle_stream() (needs sub_index, format) output_path: Path to write the extracted file Raises: RuntimeError: If ffmpeg fails. The destination is left untouched in this case — any pre-existing sidecar at ``output_path`` stays intact. """ from remux import _safe_arg_path from utils.atomic_write import atomic_write_via from utils.io_timeout import compute_io_timeout ext = os.path.splitext(output_path)[1].lower().lstrip(".") _encoder_map = {"srt": "srt", "ass": "ass", "ssa": "ass", "vtt": "webvtt"} encoder = _encoder_map.get(ext, "copy") settings = get_settings() copy_first = ext in ("ass", "ssa") and bool( getattr(settings, "embedded_extract_ass_stream_copy", False) ) # ffmpeg reads the whole file to pull one track: a remux needs longer. _timeout = compute_io_timeout(mkv_path, base=0, floor=getattr(settings, "ffmpeg_timeout", 300)) from services.media_io_gate import MediaGateBusyError, media_io_gate def _run_ffmpeg(tmp_path: str, subtitle_encoder: str) -> None: cmd = [ "ffmpeg", "-y", "-i", _safe_arg_path(mkv_path), "-map", f"0:s:{stream_info['sub_index']}", "-c:s", subtitle_encoder, _safe_arg_path(tmp_path), ] try: with media_io_gate.slot("ffmpeg subtitle extraction"): result = subprocess.run( cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=_timeout, ) except MediaGateBusyError as exc: raise _ExtractionGateBusyError(str(exc)) from None if result.returncode != 0: raise RuntimeError(f"ffmpeg extraction failed: {result.stderr}") # Refuse zero-byte output — ffmpeg sometimes returns 0 on no-data # streams or when the disk filled mid-write but the buffer flushed # cleanly enough to get a successful exit code. if os.path.getsize(tmp_path) == 0: raise RuntimeError( "ffmpeg produced an empty sidecar (likely ENOSPC or unreadable stream)" ) # A bare mkstemp here made every extracted sidecar 0600 — unreadable by the # media server — and left "tmpXXXXXXXX.srt" behind whenever the process was # killed mid-extraction. atomic_write_via relaxes the mode, uses the # sweepable prefix and reclaims stale temps in the directory first. def _write_extraction(tmp_path: str) -> None: if not copy_first: _run_ffmpeg(tmp_path, encoder) return try: _run_ffmpeg(tmp_path, "copy") except (_ExtractionGateBusyError, subprocess.TimeoutExpired): # A copy that timed out stalled on I/O, not on the codec; the # encoder would wait out a second full timeout on the same file. raise except (RuntimeError, OSError) as copy_error: # A failed ffmpeg attempt may leave partial bytes at the temp path. # Remove them before asking ffmpeg to encode into the same path. try: os.unlink(tmp_path) except FileNotFoundError: pass logger.warning( "ASS/SSA stream-copy failed; retrying with the %s encoder: %s", encoder, copy_error, ) try: _run_ffmpeg(tmp_path, encoder) except _ExtractionGateBusyError: raise except (RuntimeError, OSError, subprocess.TimeoutExpired) as fallback_error: raise RuntimeError( f"ASS/SSA stream-copy failed ({copy_error}); " f"encoder fallback failed ({fallback_error})" ) from fallback_error atomic_write_via(output_path, _write_extraction) logger.info( "Extracted %s stream %d to %s", stream_info.get("format", "?"), stream_info["sub_index"], output_path, )