Files
ubuntu-post-install/extras/sync_cc.py
T
Claude 1d4b38674d feat(extras): add sync-cc service — Whisper/ffsubsync subtitle tool
Adds sync_cc as an extras service module:
- extras/sync_cc.py: the Python tool (3196 lines) — 8 modes: SYNC,
  GENERATE, BATCH, RENAME (TMDB), EXTRACT, REMUX, EMBED, BURNSUBS
- services/sync-cc.sh: installs system deps (python3, ffmpeg, mkvtoolnix,
  ccextractor), pip installs openai-whisper + ffsubsync, copies the script
  to ~/sync-cc/, prompts for TMDB API key → .env, creates /usr/local/bin/sync-cc
  wrapper so users run it from any directory containing video/SRT files

Heavy optional deps (easyocr, pgsreader) are installed on first use by the
script itself. GPU (CUDA/MPS) is used automatically if detected.

https://claude.ai/code/session_01Y4dMKtkqkpvmgDKoRdzhTG
2026-06-03 21:36:22 +00:00

3196 lines
122 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
SRT subtitle tool - three modes:
1. GENERATE - Whisper transcribes the video and creates a perfectly-synced SRT.
2. SYNC - ffsubsync syncs an existing SRT, Whisper cross-checks the result.
3. BATCH - sync all video+SRT pairs in the current directory.
GPU is used automatically if CUDA (NVIDIA) or MPS (Apple Silicon) is detected.
openai-whisper and ffsubsync are installed automatically if missing.
Flags:
--translate Mode 2 outputs English regardless of source language
--lang CODE Source language hint (e.g. fr, id, es) — speeds up detection
--lang-auto Auto-detect language (default when --translate is used)
--extract-all FILE Non-interactive: extract all subtitle tracks and exit
Requirements:
Python 3, ffmpeg in PATH.
"""
import os, sys, re, subprocess, struct, difflib, urllib.request, urllib.parse, json, glob
from statistics import median
# ---------- .env loader -------------------------------------------------------
def _load_env():
"""Parse KEY=value lines from .env in cwd or script directory."""
import pathlib
for candidate in [pathlib.Path('.env'),
pathlib.Path(__file__).resolve().parent / '.env']:
try:
for line in candidate.read_text().splitlines():
line = line.strip()
if not line or line.startswith('#') or '=' not in line:
continue
k, _, v = line.partition('=')
k = k.strip()
v = v.strip().strip('"').strip("'")
if k and k not in os.environ:
os.environ[k] = v
except FileNotFoundError:
pass
_load_env()
# =============================================================================
# TMDB API key — set here OR put TMDB_API_KEY=your_key in a .env file
# Get a free key at https://www.themoviedb.org/settings/api
TMDB_API_KEY = os.environ.get('TMDB_API_KEY', '')
# =============================================================================
# ---------- Path setup -------------------------------------------------------
def _extend_path():
import site, pathlib
candidates = []
try:
candidates.append(site.getusersitepackages())
except Exception:
pass
home = str(pathlib.Path.home())
candidates += glob.glob(
os.path.join(home, '.local', 'lib', 'python*', 'site-packages')
)
for p in candidates:
if p and os.path.isdir(p) and p not in sys.path:
sys.path.insert(0, p)
_extend_path()
# ---------- ffsubsync finder -------------------------------------------------
def _find_ffsubsync():
import shutil, pathlib
found = shutil.which('ffsubsync')
if found:
return found
local_bin = os.path.join(str(pathlib.Path.home()), '.local', 'bin', 'ffsubsync')
if os.path.isfile(local_bin):
return local_bin
return None
# ---------- Optional dependency detection ------------------------------------
try:
import whisper as _whisper
WHISPER_AVAILABLE = True
except ImportError:
_whisper = None
WHISPER_AVAILABLE = False
FFSUBSYNC_AVAILABLE = _find_ffsubsync() is not None
TS_RE = re.compile(
r'(\d{1,2}:\d{2}:\d{2}[,\.]\d{1,3})\s*-->\s*(\d{1,2}:\d{2}:\d{2}[,\.]\d{1,3})'
)
VIDEO_EXTS = ('.mp4','.mkv','.mov','.avi','.ts','.m2ts','.webm','.flv','.wmv','.mpg','.mpeg')
# ---------- Startup diagnostic -----------------------------------------------
def _check_deps():
print("--- dependency check ---")
try:
import whisper as _w, inspect
print(f" whisper : found at {os.path.dirname(inspect.getfile(_w))}")
except ImportError:
print(" whisper : NOT found")
exe = _find_ffsubsync()
print(f" ffsubsync : {'found at ' + exe if exe else 'NOT found'}")
try:
r = subprocess.run(['ffmpeg', '-version'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
line = r.stdout.decode(errors='ignore').splitlines()[0]
print(f" ffmpeg : {line}")
except FileNotFoundError:
print(" ffmpeg : NOT found - required!")
local_paths = [p for p in sys.path if 'local' in p or 'site' in p]
if local_paths:
print(" sys.path (local/site entries):")
for p in local_paths:
print(f" {p}")
print("------------------------")
_check_deps()
# ---------- Tunable constants ------------------------------------------------
WHISPER_MODEL = "large-v3-turbo"
WHISPER_LANGUAGE = "en"
WHISPER_TASK = "transcribe" # or "translate" (→ English output)
WHISPER_MODELS = {
"1": ("tiny", "~39 MB - very fast, low accuracy"),
"2": ("base", "~74 MB - fast, basic accuracy"),
"3": ("small", "~244 MB - good for simple audio"),
"4": ("medium", "~769 MB - better accuracy, slower"),
"5": ("large-v3-turbo", "~809 MB - best speed/accuracy balance (recommended)"),
"6": ("large-v3", "~1.5 GB - highest accuracy, slowest"),
}
WHISPER_MODEL_SIZES = {
"tiny": "39 MB", "base": "74 MB", "small": "244 MB",
"medium": "769 MB", "large-v3-turbo": "809 MB", "large-v3": "1.5 GB",
}
WHISPER_PROMPT = (
"Transcript with proper punctuation, capitalization, and grammar. "
"Mark all sung lyrics and songs with ♪ symbols at the start and end. "
"Use italics tags <i></i> for off-screen or narrator dialogue."
)
START_SKIP_S = 0
ANALYZE_S = 600 # 10 minutes of audio for alignment
MIN_WORD_LEN = 4
OFFSET_AGREE_THRESHOLD = 1.5 # seconds - warn if ffsubsync and Whisper differ more than this
STOP_WORDS = {
'the','and','you','that','was','for','are','with','his','they','this',
'have','from','not','but','had','her','she','him','been','has','its',
'who','did','get','may','now','can','our','out','all','yes','no',
'what','just','will','your','when','them','than','then','some','into',
'said','more','also','very','here','well','like','even','back','much',
}
MAX_OFFSET_S = 90.0
RESOLUTION_S = 0.1
RESAMPLE_HZ = 100
SPEECH_LO = 300
SPEECH_HI = 3400
CHUNK_SIZE = max(1, int(RESAMPLE_HZ * RESOLUTION_S))
_NOISE_RE = re.compile(
r'\b(720p|1080p|2160p|4k|uhd|webrip|web|bluray|bdrip|dvdrip|hdtv|dl'
r'|x264|x265|hevc|avc|h264|h265|aac|dts|ac3|nf|amzn|hulu|dsnp|atvp'
r'|hmax|pcok|repack|proper|extended|theatrical|directors?cut|remux'
r'|episode|episodes?)\b',
re.IGNORECASE
)
_SXXEXX_RE = re.compile(r'\bS(\d{1,2})E(\d{1,2})\b', re.IGNORECASE)
_SEASON_DIR_RE = re.compile(r'^[Ss]eason[\s._-]*\d+$')
_BRACKET_RE = re.compile(r'^\s*\[[^\]]*\]\s*') # leading [SubGroup] tags
_TEXT_SUB_CODECS = {'subrip', 'srt', 'ass', 'ssa', 'mov_text',
'webvtt', 'microdvd', 'text', 'dvb_teletext'}
_IMAGE_SUB_CODECS = {'dvd_subtitle', 'hdmv_pgs_subtitle',
'dvb_subtitle', 'dvbsub', 'pgssub', 'xsub'}
# ---------- Auto-install helpers ---------------------------------------------
def _find_pip():
for cmd in (['pip3'], ['pip']):
try:
if subprocess.run(cmd + ['--version'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode == 0:
return cmd
except FileNotFoundError:
pass
for py in [sys.executable, 'python3', 'python']:
try:
if subprocess.run([py, '-m', 'pip', '--version'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode == 0:
return [py, '-m', 'pip']
except FileNotFoundError:
pass
# Try bootstrapping pip via ensurepip
try:
if subprocess.run([sys.executable, '-m', 'ensurepip', '--upgrade'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode == 0:
if subprocess.run([sys.executable, '-m', 'pip', '--version'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode == 0:
return [sys.executable, '-m', 'pip']
except Exception:
pass
# Last resort: apt-get
print(" pip not found - attempting: sudo apt-get install python3-pip ...")
try:
if subprocess.run(['sudo', 'apt-get', 'install', '-y', 'python3-pip'],
timeout=120).returncode == 0:
for cmd in (['pip3'], [sys.executable, '-m', 'pip']):
try:
if subprocess.run(cmd + ['--version'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode == 0:
return cmd
except FileNotFoundError:
pass
except Exception:
pass
return None
def _pip_install(package):
pip = _find_pip()
if pip is None:
print(f" Cannot find pip. Try manually: pip3 install {package}")
return False
for flags in [[], ['--user']]:
if subprocess.run(pip + ['install'] + flags + [package]).returncode == 0:
_extend_path()
return True
print(" Standard and --user installs failed.")
if input(" Try --break-system-packages? [y/N]: ").strip().lower() == 'y':
if subprocess.run(pip + ['install', '--break-system-packages',
package]).returncode == 0:
_extend_path()
return True
return False
def ensure_whisper():
global _whisper, WHISPER_AVAILABLE
if WHISPER_AVAILABLE:
return True
print("\nopenai-whisper is not installed.")
if input("Install it now? [y/N]: ").strip().lower() != 'y':
print("Skipping - will fall back to audio energy method.")
return False
print("Installing openai-whisper...")
if not _pip_install('openai-whisper'):
print("Installation failed.")
return False
import importlib
importlib.invalidate_caches()
try:
import whisper as _w
_whisper = _w
WHISPER_AVAILABLE = True
print("Installed successfully.\n")
return True
except ImportError:
print("Installed but import failed - try restarting the script.")
return False
def ensure_ffsubsync():
global FFSUBSYNC_AVAILABLE
if FFSUBSYNC_AVAILABLE:
return True
print("\nffsubsync is not installed (recommended for syncing existing SRTs).")
if input("Install it now? [y/N]: ").strip().lower() != 'y':
return False
print("Installing ffsubsync...")
if not _pip_install('ffsubsync'):
print("Installation failed.")
return False
import importlib
importlib.invalidate_caches()
if _find_ffsubsync():
FFSUBSYNC_AVAILABLE = True
print("ffsubsync installed successfully.")
return True
print("Installed but ffsubsync not found - try restarting the script.")
return False
def ensure_easyocr():
try:
import easyocr # noqa: F401
return True
except ImportError:
pass
print("\neasyocr not installed (needed to scan video frames for a title card).")
if input("Install it now? (~200 MB package, ~170 MB model download on first use) [y/N]: ").strip().lower() != 'y':
return False
print("Installing easyocr...")
if not _pip_install('easyocr'):
print("Installation failed.")
return False
import importlib
importlib.invalidate_caches()
try:
import easyocr # noqa: F401
return True
except ImportError:
print("Installed but import failed - try restarting the script.")
return False
def ensure_ccextractor():
"""Return ccextractor command, or None if unavailable."""
for cmd in ['ccextractor', 'ccextractorwin', 'ccx']:
try:
if subprocess.run([cmd, '--version'],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode in (0, 1):
return cmd
except FileNotFoundError:
pass
print("\nccextractor not found (needed for CC and some DVD subtitles).")
if input("Try to install via apt-get? [y/N]: ").strip().lower() != 'y':
print(" Install manually: https://ccextractor.org")
return None
try:
if subprocess.run(['sudo', 'apt-get', 'install', '-y', 'ccextractor'],
timeout=120).returncode == 0:
return 'ccextractor'
except Exception:
pass
print(" apt-get failed. Install manually: https://ccextractor.org")
return None
def ensure_pgsreader():
try:
import pgsreader # noqa: F401
return True
except ImportError:
pass
print("\npgsreader not installed (needed for Blu-ray PGS subtitles).")
if input("Install it now? [y/N]: ").strip().lower() != 'y':
return False
if not _pip_install('pgsreader'):
return False
import importlib
importlib.invalidate_caches()
try:
import pgsreader # noqa: F401
return True
except ImportError:
print("Installed but import failed - try restarting the script.")
return False
def ensure_mkvtoolnix():
"""Return True if mkvmerge is available, offering to install if not."""
import shutil, platform
if shutil.which('mkvmerge'):
return True
print("\nmkvmerge not found — needed to embed subtitles into MKV files.")
system = platform.system()
if system == 'Darwin':
if input(" Try to install via brew? [y/N]: ").strip().lower() == 'y':
try:
if subprocess.run(['brew', 'install', 'mkvtoolnix'],
timeout=300).returncode == 0:
return bool(shutil.which('mkvmerge'))
except Exception:
pass
print(" Install manually: brew install mkvtoolnix")
else:
if input(" Try to install via apt-get? [y/N]: ").strip().lower() == 'y':
try:
if subprocess.run(['sudo', 'apt-get', 'install', '-y', 'mkvtoolnix'],
timeout=120).returncode == 0:
return bool(shutil.which('mkvmerge'))
except Exception:
pass
print(" Install manually:")
print(" Debian/Ubuntu : sudo apt install mkvtoolnix")
print(" Arch : sudo pacman -S mkvtoolnix-cli")
print(" Other : https://mkvtoolnix.download/")
return False
def ensure_vobsub2srt():
"""Return True if vobsub2srt is available, offering to install if not."""
import shutil
if shutil.which('vobsub2srt'):
return True
print("\nvobsub2srt not found — needed for DVD VOB subtitle OCR to SRT.")
if input(" Try to install via apt-get? [y/N]: ").strip().lower() == 'y':
try:
if subprocess.run(['sudo', 'apt-get', 'install', '-y', 'vobsub2srt'],
timeout=120).returncode == 0:
return bool(shutil.which('vobsub2srt'))
except Exception:
pass
print(" Install manually: sudo apt install vobsub2srt")
print(" Alternative GUI : https://github.com/SubtitleEdit/subtitleedit")
return False
# ---------- GPU detection ----------------------------------------------------
def get_device():
try:
import torch
if torch.cuda.is_available():
print(f" GPU detected: {torch.cuda.get_device_name(0)} (CUDA)")
return "cuda"
if hasattr(torch.backends, 'mps') and torch.backends.mps.is_available():
print(" GPU detected: Apple Silicon (MPS)")
return "mps"
except Exception:
pass
print(" No GPU detected - running on CPU.")
return "cpu"
def load_whisper_model(model_name):
device = get_device()
size = WHISPER_MODEL_SIZES.get(model_name, '?')
print(f" Loading Whisper '{model_name}' model "
f"(first run downloads ~{size} to ~/.cache/whisper)...")
try:
return _whisper.load_model(model_name, device=device), device
except Exception as e:
if 'out of memory' in str(e).lower() and device != 'cpu':
print(" GPU out of memory - clearing cache and retrying on CPU...")
try:
import torch
torch.cuda.empty_cache()
torch.cuda.synchronize()
except Exception:
pass
return _whisper.load_model(model_name, device='cpu'), 'cpu'
raise
# ---------- File listing / selection -----------------------------------------
def list_files(exts, label):
exts = (exts,) if isinstance(exts, str) else exts
files = [f for f in sorted(os.listdir('.')) if f.lower().endswith(exts)]
if not files:
print(f"No {label} files found in current directory.")
else:
for i, f in enumerate(files, 1):
print(f"{i}: {f}")
return files
def pick_file(files, prompt, allow_skip=False):
skip_hint = " or Enter to skip" if allow_skip else ""
while True:
choice = input(prompt + skip_hint + " (0 to cancel): ").strip()
if choice == '0':
return None
if choice == "" and allow_skip:
return ""
if choice == "":
for i, f in enumerate(files, 1):
print(f"{i}: {f}")
continue
if choice.isdigit():
idx = int(choice)
if 1 <= idx <= len(files):
return files[idx - 1]
print("Invalid number.")
continue
if os.path.isfile(choice):
return choice
print("File not found.")
# ---------- SRT parsing / writing --------------------------------------------
def srt_to_seconds(t):
h, m, rest = t.split(':')
s, ms = rest.split(',')
return int(h)*3600 + int(m)*60 + int(s) + int(ms)/1000.0
def seconds_to_srt(t):
t = max(0.0, t)
h = int(t) // 3600
m = (int(t) // 60) % 60
s = int(t) % 60
ms = int(round((t - int(t)) * 1000))
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
def _read_srt_text(path):
"""Read an SRT file, auto-detecting encoding and stripping BOM."""
for enc in ('utf-8-sig', 'utf-16', 'cp1252', 'latin-1'):
try:
text = open(path, encoding=enc).read()
# utf-16 files decoded correctly won't have lone surrogates
return text
except (UnicodeDecodeError, UnicodeError):
continue
return open(path, encoding='utf-8', errors='replace').read()
def _normalise_srt_ts(text):
"""Accept HH:MM:SS.mmm or H:MM:SS,mm etc. — normalise to HH:MM:SS,mmm."""
def _fix(m):
ts = m.group(0)
ts = ts.replace('.', ',')
ms_part = ts.rsplit(',', 1)[1]
ts = ts.rsplit(',', 1)[0] + ',' + ms_part.ljust(3, '0')[:3]
return ts
return re.sub(r'\d{1,2}:\d{2}:\d{2}[,\.]\d{1,3}', _fix, text)
_HI_LINE_RE = re.compile(r'^\s*[\(\[].+[\)\]]\s*$') # lines that are ONLY a bracketed description
def _is_hi_subtitle(path):
"""Return True if >25% of text lines look like HI sound descriptions."""
entries = parse_srt_full(path, limit=80)
if not entries:
return False
total = hi = 0
for _, _, text in entries:
for line in text.splitlines():
line = line.strip()
if not line:
continue
total += 1
if _HI_LINE_RE.match(line):
hi += 1
return total > 0 and (hi / total) > 0.25
def _strip_hi_for_sync(src_path, dst_path):
"""Write a copy of src_path with description-only entries removed.
Entries that mix dialogue with descriptions are kept (stripped to dialogue only).
Returns True if any entries were removed/modified."""
text = _normalise_srt_ts(_read_srt_text(src_path))
blocks = re.split(r'\n\s*\n', text.strip())
out = []
changed = False
for block in blocks:
lines = block.strip().splitlines()
ts_idx = next((i for i, l in enumerate(lines) if TS_RE.search(l)), None)
if ts_idx is None:
out.append(block)
continue
text_lines = [l for l in lines[ts_idx + 1:] if l.strip()]
dialogue = [l for l in text_lines if not _HI_LINE_RE.match(l)]
if not text_lines:
out.append(block)
elif not dialogue:
# entry is entirely sound descriptions — drop it
changed = True
else:
if len(dialogue) < len(text_lines):
changed = True
out.append('\n'.join(lines[:ts_idx + 1] + dialogue))
with open(dst_path, 'w', encoding='utf-8') as f:
f.write('\n\n'.join(out))
return changed
def parse_srt_full(path, limit=9999):
entries = []
try:
text = _normalise_srt_ts(_read_srt_text(path))
except Exception:
return entries
for block in re.split(r'\n\s*\n', text.strip()):
lines = block.strip().splitlines()
for i, line in enumerate(lines):
m = TS_RE.search(line)
if m:
start = srt_to_seconds(m.group(1))
end = srt_to_seconds(m.group(2))
body = re.sub(r'<[^>]+>', '', ' '.join(lines[i+1:]).strip())
entries.append((start, end, body))
break
if len(entries) >= limit:
break
return entries
def normalize_word(w):
return re.sub(r"[^a-z0-9']", '', w.lower())
def srt_to_word_times(entries):
result = []
for start, _end, text in entries:
for raw in text.split():
w = normalize_word(raw)
if len(w) >= MIN_WORD_LEN and w not in STOP_WORDS:
result.append((w, start))
return result
def shift_srt(inpath, outpath, offset):
text = _normalise_srt_ts(_read_srt_text(inpath))
with open(outpath, 'w', encoding='utf-8') as fout, \
__import__('io').StringIO(text) as fin:
for line in fin:
m = TS_RE.search(line)
if m:
s = srt_to_seconds(m.group(1)) + offset
e = srt_to_seconds(m.group(2)) + offset
fout.write(f"{seconds_to_srt(s)} --> {seconds_to_srt(e)}\n")
else:
fout.write(line)
def parse_offset(s):
try:
return float(s)
except Exception:
return None
# ---------- Filename / show info parsing -------------------------------------
def extract_show_info(filepath, extra_paths=None):
"""
Extract (show_name, SxxExx) by checking, in order:
1. The video filename
2. Any extra_paths (e.g. matching SRT filename)
3. Directory path components (handles SxxExx in a folder name)
4. Plex-style layout: .../Show Name/Season NN/file
5. Immediate parent directory name as a last resort
"""
show = ''
episode = ''
def _parse_name(path):
base = os.path.splitext(os.path.basename(path))[0]
base = _BRACKET_RE.sub('', base) # strip leading [SubGroup]
base = re.sub(r'[._]', ' ', base)
m = _SXXEXX_RE.search(base)
if m:
s = _NOISE_RE.sub('', base[:m.start()]).strip()
return re.sub(r'\s+', ' ', s).strip(), m.group(0).upper()
s = _NOISE_RE.sub('', base).strip()
return re.sub(r'\s+', ' ', s).strip(), ''
for path in [filepath] + (extra_paths or []):
s, e = _parse_name(path)
if not show and s:
show = s
if not episode and e:
episode = e
if show and episode:
break
if not show or not episode:
parts = os.path.normpath(os.path.abspath(filepath)).split(os.sep)
for part in reversed(parts[:-1]):
part_clean = re.sub(r'[._]', ' ', part)
m = _SXXEXX_RE.search(part_clean)
if m:
if not episode:
episode = m.group(0).upper()
if not show:
s = _NOISE_RE.sub('', part_clean[:m.start()]).strip()
show = re.sub(r'\s+', ' ', s).strip()
if not show:
for i, part in enumerate(parts):
if _SEASON_DIR_RE.match(part) and i > 0:
show = re.sub(r'[._]', ' ', parts[i - 1]).strip()
show = re.sub(r'\s+', ' ', show).strip()
break
if not show:
parent = os.path.basename(os.path.dirname(os.path.abspath(filepath)))
if parent not in ('', '.') and not _SEASON_DIR_RE.match(parent):
show = re.sub(r'[._]', ' ', parent).strip()
show = re.sub(r'\s+', ' ', show).strip()
return show, episode
# ---------- Text post-processing ---------------------------------------------
def postprocess_text(text):
text = text.strip()
if not text:
return text
# OCR misreads \u266a as $. Strip $ embedded inside words; replace remaining
# $ (not before a digit) with \u266a so music-note lines are handled correctly.
text = re.sub(r'(?<=[A-Za-z])\$(?=[A-Za-z])', '', text)
text = re.sub(r'\$(?!\d)', '\u266a', text)
music_rx = re.compile(
r'\[\s*(music|singing|song|humming|instrumental|melody)\s*\]',
re.IGNORECASE
)
has_music = bool(music_rx.search(text)) or '\u266a' in text
text = music_rx.sub('\u266a', text)
text = re.sub(r'\[[^\]]{1,40}\]', '', text).strip()
text = re.sub(r' +', ' ', text).strip()
if has_music:
core = re.sub(r'[\u266a]+', '', text).strip()
text = f'\u266a {core} \u266a' if core else '\u266a'
if text.startswith('\u266a'):
after = text[1:].lstrip()
if after and after[0].islower():
text = '\u266a ' + after[0].upper() + after[1:]
elif text and text[0].islower():
text = text[0].upper() + text[1:]
return text
# ---------- SRT vocabulary extraction ----------------------------------------
def extract_srt_vocab(srt_path, max_words=60):
entries = parse_srt_full(srt_path)
proper = {}
for _, _, text in entries:
words = text.split()
for i, raw in enumerate(words):
w = re.sub(r"[^a-zA-Z']", '', raw)
if not w:
continue
if i > 0 and w[0].isupper() and w.lower() not in STOP_WORDS:
proper[w] = proper.get(w, 0) + 1
return sorted(proper, key=lambda w: -proper[w])[:max_words]
def build_prompt(video_path, srt_path=None):
show, episode = extract_show_info(video_path)
prompt = WHISPER_PROMPT
if show:
prompt += f" This is '{show}'"
prompt += f", {episode}." if episode else "."
if srt_path and os.path.isfile(srt_path):
vocab = extract_srt_vocab(srt_path)
if vocab:
prompt += f" Vocabulary: {', '.join(vocab)}."
return prompt
# ---------- Mode 2: Generate SRT from scratch --------------------------------
def _detect_language(video_path, model):
"""Sample 30 s of audio and return (code, confidence, display_name)."""
import numpy as np
raw = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video_path, '-t', '30',
'-vn', '-ac', '1', '-ar', '16000', '-f', 'f32le', 'pipe:1'
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=60).stdout
n = len(raw) // 4
if n == 0:
return None, None, None
audio = np.frombuffer(raw, dtype=np.float32).copy()
audio = _whisper.pad_or_trim(audio)
n_mels = getattr(getattr(model, 'dims', None), 'n_mels', 80)
mel = _whisper.log_mel_spectrogram(audio, n_mels=n_mels).to(model.device)
_, probs = model.detect_language(mel)
code = max(probs, key=probs.get)
conf = probs[code]
names = getattr(_whisper.tokenizer, 'LANGUAGES', {})
name = names.get(code, code).title()
return code, conf, name
def generate_srt(video_path, output_path, model_name, srt_path=None,
task='transcribe', language=None, _model=None):
if _model is None:
_model, _ = load_whisper_model(model_name)
show, episode = extract_show_info(video_path)
if show:
print(f" Detected show: '{show}'" + (f" Episode: {episode}" if episode else ""))
if srt_path:
print(f" Vocabulary seeded from: {os.path.basename(srt_path)}")
if task == 'translate':
hint = f" (source: {language})" if language else " (auto-detect source)"
print(f" Translating to English{hint} - lines will appear as recognised...")
else:
print(" Transcribing - lines will appear as they are recognised...")
result = _model.transcribe(video_path,
initial_prompt=build_prompt(video_path, srt_path),
language=language,
task=task,
verbose=True)
segs = result.get('segments', [])
idx = 0
with open(output_path, 'w', encoding='utf-8') as f:
for seg in segs:
txt = postprocess_text(seg['text'])
if not txt:
continue
idx += 1
f.write(f"{idx}\n")
f.write(f"{seconds_to_srt(seg['start'])} --> {seconds_to_srt(seg['end'])}\n")
f.write(f"{txt}\n\n")
return idx, output_path
def scan_title_card(video_path, start=20, duration=160, interval=5):
"""
Extract frames from the video and OCR them to find on-screen episode title cards.
Returns list of (text, frame_count, timestamp_seconds) sorted by frame count.
"""
try:
import easyocr
except ImportError:
print(" easyocr not available.")
return []
import tempfile, glob
end = start + duration
print(f" Extracting frames ({start}s {end}s, one every {interval}s)...")
seen = {} # lower-normalised key -> (original_case, count, first_timestamp)
with tempfile.TemporaryDirectory() as tmpdir:
frame_pattern = os.path.join(tmpdir, 'frame_%04d.png')
r = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-ss', str(start), '-i', video_path,
'-t', str(duration),
'-vf', f'fps=1/{interval},scale=1280:-1',
frame_pattern
], timeout=120)
frames = sorted(glob.glob(os.path.join(tmpdir, 'frame_*.png')))
if not frames:
print(" No frames extracted.")
return []
print(f" Running OCR on {len(frames)} frames"
f" (first run downloads ~170 MB model)...")
reader = easyocr.Reader(['en'], verbose=False)
for frame_idx, frame_path in enumerate(frames):
ts = start + frame_idx * interval
try:
results = reader.readtext(frame_path, detail=1, paragraph=False)
frame_seen = set()
for (_, text, conf) in results:
text = text.strip()
if conf < 0.4:
continue
words = text.split()
if not (2 <= len(words) <= 8) or not (4 <= len(text) <= 60):
continue
if re.search(r'[©®@]|\d{2}:\d{2}|www\.', text):
continue
key = re.sub(r'\s+', ' ', text).lower()
if key not in frame_seen:
frame_seen.add(key)
if key in seen:
seen[key] = (seen[key][0], seen[key][1] + 1, seen[key][2])
else:
seen[key] = (text, 1, ts)
except Exception:
continue
return sorted(seen.values(), key=lambda x: -x[1])
def _timed_input(prompt, timeout=15):
"""Print prompt and wait for Enter; auto-continues after timeout seconds."""
import select as _sel
print(prompt, end='', flush=True)
ready, _, _ = _sel.select([sys.stdin], [], [], timeout)
if ready:
sys.stdin.readline()
else:
print(f" (timed out after {timeout}s)")
def _preview_frame(video_path, timestamp):
"""Extract the frame at timestamp and open it in the system image viewer."""
import tempfile
fd, png = tempfile.mkstemp(suffix='.png', prefix='cc_preview_')
os.close(fd)
try:
subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-ss', str(timestamp), '-i', video_path,
'-frames:v', '1', '-y', png
], timeout=30, check=True)
viewer = 'open' if sys.platform == 'darwin' else 'xdg-open'
subprocess.Popen([viewer, png])
_timed_input(" (Press Enter to continue, auto-closes in 15s...)", timeout=15)
except Exception as e:
print(f" Preview failed: {e}")
finally:
try:
os.unlink(png)
except Exception:
pass
def _sync_pass(video_path, whisper_out, final_out, ffsubsync_ok):
"""Run ffsubsync on whisper_out → final_out. Returns path of best result."""
if not ffsubsync_ok:
print(" ffsubsync not available, skipping timing pass.")
return whisper_out
ok, offset = sync_with_ffsubsync(video_path, whisper_out, final_out)
if ok:
if offset is not None:
print(f" Timing adjusted by {offset:+.3f} s")
return final_out
print(" ffsubsync timing pass failed - using Whisper output as-is.")
return whisper_out
def generate_and_sync(video_path, model_name, srt_path=None, ffsubsync_ok=False):
"""Load model once, detect language, ask user, then transcribe/translate/both."""
global WHISPER_TASK, WHISPER_LANGUAGE
base = os.path.splitext(video_path)[0]
model, _ = load_whisper_model(model_name)
# --- Language detection ---
print("\n Detecting language from first 30 seconds...")
lang_code, conf, lang_name = _detect_language(video_path, model)
if lang_code:
print(f" Detected: {lang_name} ({lang_code}) {conf*100:.0f}% confidence")
else:
print(" Language detection failed — defaulting to current setting.")
lang_code = WHISPER_LANGUAGE
is_english = lang_code in ('en', None)
# --- Skip choice if --translate was passed explicitly ---
if WHISPER_TASK == 'translate' and not is_english:
task = 'translate'
src_lang = lang_code
do_orig = False
do_en = True
elif is_english:
task = 'transcribe'
src_lang = lang_code
do_orig = True
do_en = False
else:
# Non-English detected — ask what to generate
print(f"\n Source language: {lang_name}. What would you like?")
print(f" 1: {lang_name} SRT - transcribe in original language")
print( " 2: English SRT - translate to English")
print(f" 3: Both - {lang_name} + English SRT")
print( " 0: Cancel")
while True:
ch = input(" Choose [2]: ").strip() or '2'
if ch in ('0', '1', '2', '3'):
break
print(" Enter 0-3.")
if ch == '0':
return None
do_orig = ch in ('1', '3')
do_en = ch in ('2', '3')
src_lang = lang_code
outputs = []
# --- Original language pass ---
if do_orig:
suffix = f'-whisper-{src_lang}' if src_lang and src_lang != 'en' else '-whisper'
w_out = f"{base}{suffix}.srt"
f_out = f"{base}{suffix}-synced.srt"
print(f"\nWhisper transcription → {os.path.basename(w_out)}")
n, _ = generate_srt(video_path, w_out, model_name,
srt_path=srt_path, task='transcribe',
language=src_lang, _model=model)
print(f" {n} segments written.")
print(f"\nffsubsync timing pass → {os.path.basename(f_out)}")
outputs.append(_sync_pass(video_path, w_out, f_out, ffsubsync_ok))
# --- English translation pass ---
if do_en:
w_out = f"{base}-whisper-en.srt"
f_out = f"{base}-whisper-en-synced.srt"
print(f"\nWhisper translation → English → {os.path.basename(w_out)}")
n, _ = generate_srt(video_path, w_out, model_name,
srt_path=srt_path, task='translate',
language=src_lang, _model=model)
print(f" {n} segments written.")
print(f"\nffsubsync timing pass → {os.path.basename(f_out)}")
outputs.append(_sync_pass(video_path, w_out, f_out, ffsubsync_ok))
return outputs[-1] if outputs else None
# ---------- ffsubsync --------------------------------------------------------
def sync_with_ffsubsync(video_path, srt_path, output_path):
exe = _find_ffsubsync()
if not exe:
return False, None
import tempfile
# HI subtitles (hearing impaired) have many [sound] descriptions that
# don't correspond to speech, wrecking VAD-based cross-correlation.
# Sync on a dialogue-only copy; apply the resulting offset to the original.
hi = _is_hi_subtitle(srt_path)
if hi:
print(" Detected HI (hearing-impaired) subtitle — stripping sound "
"descriptions for sync pass, will reapply to original.")
fd, stripped_path = tempfile.mkstemp(suffix='.srt')
os.close(fd)
_strip_hi_for_sync(srt_path, stripped_path)
sync_src = stripped_path
else:
stripped_path = None
sync_src = srt_path
print(" Running ffsubsync (WebRTC VAD + FFT) - usually 20-30 seconds...")
result = subprocess.run(
[exe, video_path, '-i', sync_src, '-o', output_path],
capture_output=True, text=True
)
if stripped_path:
try:
os.remove(stripped_path)
except OSError:
pass
combined = result.stdout + result.stderr
if result.returncode != 0 or not os.path.isfile(output_path):
return False, None
# Parse scale factor; if significant, apply it to correct framerate drift.
# A plain offset fixes a constant gap; scaling fixes drift that grows over
# time when the SRT was authored for a different framerate than the video.
scale_m = re.search(r'framerate scale factor[:\s]+([\d.]+)', combined)
if scale_m:
scale = float(scale_m.group(1))
if not 0.98 <= scale <= 1.02:
src_fps = 'NTSC 23.976' if scale < 1.0 else 'PAL 25'
vid_fps = 'PAL 25' if scale < 1.0 else 'NTSC 23.976'
drift = abs(1.0 - scale) * 100
print(f" Framerate mismatch: SRT={src_fps}fps, video={vid_fps}fps "
f"(scale {scale:.4f}, ~{drift:.1f}% drift) — applying correction.")
scaled = parse_srt_full(output_path)
with open(output_path, 'w', encoding='utf-8') as _f:
for _i, (_s, _e, _t) in enumerate(scaled, 1):
_f.write(f"{_i}\n{seconds_to_srt(_s * scale)} --> "
f"{seconds_to_srt(_e * scale)}\n{_t}\n\n")
# If HI, we got a synced version of the stripped file; now shift the
# original (with all descriptions) by the same offset instead.
def first_ts(path):
try:
for line in _read_srt_text(path).splitlines():
m = TS_RE.search(line)
if m:
return srt_to_seconds(m.group(1))
except Exception:
pass
return None
t_orig = first_ts(srt_path)
t_synced = first_ts(output_path)
offset = (t_synced - t_orig) if (t_orig is not None and t_synced is not None) else None
if hi and offset is not None:
# Replace ffsubsync's output (stripped) with shifted original (full HI)
shift_srt(srt_path, output_path, offset)
return True, offset
# ---------- Whisper word alignment -------------------------------------------
def whisper_word_times(video_path, model_name, srt_path=None):
import numpy as np
model, device = load_whisper_model(model_name)
print(f" Extracting audio (first {ANALYZE_S//60} min)...")
raw = subprocess.run([
'ffmpeg', '-hide_banner',
'-i', video_path,
'-t', str(ANALYZE_S),
'-vn', '-ac', '1', '-ar', '16000',
'-f', 'f32le', 'pipe:1'
], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=300).stdout
n = len(raw) // 4
if n == 0:
raise RuntimeError("ffmpeg returned no audio.")
audio = np.frombuffer(raw, dtype=np.float32).copy()
print(" Transcribing...")
result = model.transcribe(audio,
initial_prompt=build_prompt(video_path, srt_path),
language=WHISPER_LANGUAGE,
word_timestamps=True, verbose=False)
words = []
for seg in result.get('segments', []):
for wd in seg.get('words', []):
w = normalize_word(wd.get('word', ''))
if len(w) >= MIN_WORD_LEN and w not in STOP_WORDS:
words.append((w, wd['start']))
return words
def compute_offset_whisper(srt_path, video_path, model_name):
entries = parse_srt_full(srt_path)
if not entries:
return None, 0, 0, "No entries found in SRT."
window_entries = [(s, e, t) for s, e, t in entries
if s <= ANALYZE_S + MAX_OFFSET_S]
if not window_entries:
print(" Warning: no SRT entries in analysis window - using first 100.")
window_entries = entries[:100]
srt_wt = srt_to_word_times(window_entries)
if not srt_wt:
return None, 0, 0, "No usable words in SRT window."
print(f" Analysis window: 0-{ANALYZE_S}s | {len(window_entries)} SRT cues")
try:
whi_wt = whisper_word_times(video_path, model_name, srt_path)
except Exception as e:
return None, 0, 0, f"Whisper failed: {e}"
if not whi_wt:
return None, 0, 0, "Whisper produced no output."
print(f" SRT: {len(srt_wt)} words | Whisper: {len(whi_wt)} words")
print(" Aligning word sequences...")
matcher = difflib.SequenceMatcher(
None, [w for w, _ in srt_wt], [w for w, _ in whi_wt], autojunk=False
)
raw_offsets = []
for i, j, n in matcher.get_matching_blocks():
for k in range(n):
raw_offsets.append(whi_wt[j+k][1] - srt_wt[i+k][1])
if len(raw_offsets) < 5:
return None, len(raw_offsets), 0, (
f"Only {len(raw_offsets)} word matches. Is this SRT for this video?"
)
rough = median(raw_offsets)
cleaned = [o for o in raw_offsets if abs(o - rough) <= 2.0]
if len(cleaned) < 5:
cleaned = raw_offsets
off = median(cleaned)
spread = max(cleaned) - min(cleaned)
print(f" Matches after outlier filter: {len(cleaned)}/{len(raw_offsets)}")
return off, len(cleaned), spread, None
# ---------- Whisper cross-check of ffsubsync result --------------------------
def whisper_verify(srt_path, video_path, model_name, ffsubsync_offset):
print(" Verifying with Whisper word alignment...")
w_offset, n_matches, spread, err = compute_offset_whisper(
srt_path, video_path, model_name
)
if err:
return None, None, 0, 0, err
agree = abs(w_offset - ffsubsync_offset) <= OFFSET_AGREE_THRESHOLD
return agree, w_offset, n_matches, spread, None
# ---------- Fallback: speech-band energy cross-correlation ------------------
def extract_speech_energy(video_path):
total = int(ANALYZE_S / RESOLUTION_S) + 1
cmd = [
'ffmpeg', '-hide_banner',
'-i', video_path,
'-t', str(ANALYZE_S), '-vn', '-ac', '1',
'-af', f'highpass=f={SPEECH_LO},lowpass=f={SPEECH_HI}',
'-ar', str(RESAMPLE_HZ), '-f', 'f32le', 'pipe:1'
]
try:
r = subprocess.run(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, timeout=300)
raw = r.stdout
n = len(raw) // 4
if n == 0:
return []
samples = struct.unpack(f'<{n}f', raw)
energy = [0.0] * total
for i in range(0, n, CHUNK_SIZE):
seg = samples[i:i+CHUNK_SIZE]
rms = (sum(x*x for x in seg) / len(seg)) ** 0.5
bi = i // CHUNK_SIZE
if bi < total:
energy[bi] = rms
return energy
except Exception as e:
print(f" Audio extraction error: {e}")
return []
def compute_onsets(energy, lookback=2):
onsets = [0.0] * len(energy)
for i in range(lookback, len(energy)):
d = energy[i] - energy[i - lookback]
if d > 0:
onsets[i] = d
nz = sorted(o for o in onsets if o > 0)
if nz:
thr = nz[len(nz) // 2]
onsets = [o if o >= thr else 0.0 for o in onsets]
return onsets
def crosscorr_offset(entries, energy):
n_bins = len(energy)
max_lag = int(MAX_OFFSET_S / RESOLUTION_S)
onsets = compute_onsets(energy)
seen, starts = set(), []
for s, _e, _t in entries:
si = max(0, int(s / RESOLUTION_S))
if si not in seen:
starts.append(si)
seen.add(si)
if not starts or not any(onsets):
return 0.0, 0.0
scores = [
sum(onsets[i+lag] for i in starts if 0 <= i+lag < n_bins)
for lag in range(-max_lag, max_lag + 1)
]
best = max(range(len(scores)), key=lambda i: scores[i])
mean = sum(scores) / len(scores)
return (best - max_lag) * RESOLUTION_S, scores[best] / max(1e-9, mean)
def compute_offset_fallback(srt_path, video_path):
entries = parse_srt_full(srt_path)
if not entries:
return None, None, "No entries in SRT."
print(" Extracting speech-band audio energy...")
energy = extract_speech_energy(video_path)
if not energy or not any(energy):
return None, None, "Could not extract audio from video."
print(" Running onset cross-correlation...")
offset, conf = crosscorr_offset(entries, energy)
return offset, conf, None
# ---------- Core sync logic (used by Mode 2 and batch) ----------------------
def sync_single(video, src, out, ffsubsync_ok, whisper_ok, interactive=True):
"""
Sync src SRT to video, writing result to out.
ffsubsync runs first; Whisper independently verifies the offset.
interactive=True prompts user on disagreement; False just warns and keeps ffsubsync.
Returns True on success.
"""
synced = False
final_offset = None
# Primary: ffsubsync
if ffsubsync_ok:
ok, fs_offset = sync_with_ffsubsync(video, src, out)
if ok:
if fs_offset is not None:
print(f" ffsubsync offset : {fs_offset:+.3f} s")
# Cross-check with Whisper
if whisper_ok and fs_offset is not None:
agree, w_offset, n_matches, spread, err = whisper_verify(
src, video, WHISPER_MODEL, fs_offset
)
if err:
print(f" Whisper verify skipped: {err}")
else:
quality = ("good" if spread < 2.0 else
"moderate" if spread < 5.0 else "low")
diff = abs(w_offset - fs_offset)
print(f" Whisper offset : {w_offset:+.3f} s "
f"({n_matches} words, spread {spread:.1f}s, {quality})")
if agree:
print(f" Agreement : YES (differ by {diff:.2f}s) "
f"- using ffsubsync result.")
else:
print(f" Agreement : NO (differ by {diff:.2f}s, "
f"threshold {OFFSET_AGREE_THRESHOLD}s)")
if interactive:
print(f" [f] Use ffsubsync ({fs_offset:+.3f}s)")
print(f" [w] Use Whisper ({w_offset:+.3f}s)")
print(f" [e] Enter offset manually")
while True:
choice = input(" Choose [f/w/e]: ").strip().lower()
if choice == 'f':
print(" Using ffsubsync offset.")
break
elif choice == 'w':
print(" Re-applying Whisper offset...")
shift_srt(src, out, w_offset)
final_offset = w_offset
break
elif choice == 'e':
while True:
resp = input(" Enter offset in seconds: ").strip()
manual = parse_offset(resp)
if manual is not None:
shift_srt(src, out, manual)
final_offset = manual
break
print(" Invalid number.")
break
else:
print(f" WARNING: methods disagree by {diff:.2f}s. "
f"Keeping ffsubsync - review manually.")
synced = True
final_offset = final_offset or fs_offset
else:
print(" ffsubsync failed - falling back to Whisper...")
# Fallback 1: Whisper word alignment
if not synced and whisper_ok:
offset, n_matches, spread, err = compute_offset_whisper(src, video, WHISPER_MODEL)
if not err:
quality = ("good" if spread < 2.0 else
"moderate" if spread < 5.0 else "low")
print(f" Whisper offset: {offset:+.3f} s "
f"({n_matches} matches, spread {spread:.1f}s, {quality})")
shift_srt(src, out, offset)
final_offset = offset
synced = True
else:
print(f" Whisper failed: {err}")
print(" Trying audio energy cross-correlation...")
# Fallback 2: energy cross-correlation
if not synced:
offset, conf, err = compute_offset_fallback(src, video)
if not err:
q = "LOW" if conf < 1.5 else "moderate" if conf < 2.5 else "good"
print(f" Energy offset: {offset:+.3f} s (confidence {conf:.2f}x, {q})")
shift_srt(src, out, offset)
final_offset = offset
synced = True
else:
print(f" All methods failed: {err}")
if synced and final_offset is not None:
print(f" Final offset: {final_offset:+.3f} s")
return synced
# ---------- Batch helpers ----------------------------------------------------
def find_srt_for_video(video_path, srt_files):
_, ep = extract_show_info(video_path)
ep_lower = ep.lower() if ep else None
base = os.path.splitext(os.path.basename(video_path))[0]
candidates = [f for f in srt_files
if not f.lower().endswith('-synced.srt')
and not f.lower().endswith('-whisper.srt')]
if ep_lower:
ep_matches = [f for f in candidates if ep_lower in f.lower()]
if ep_matches:
return sorted(ep_matches, key=len)[0]
exact = base + '.srt'
if exact in candidates:
return exact
return None
def batch_sync(ffsubsync_ok, whisper_ok):
video_files = [f for f in sorted(os.listdir('.'))
if f.lower().endswith(VIDEO_EXTS)]
srt_files = [f for f in sorted(os.listdir('.'))
if f.lower().endswith('.srt')]
if not video_files:
print("No video files found.")
return
if not srt_files:
print("No SRT files found.")
return
pairs, unmatched = [], []
for vf in video_files:
sf = find_srt_for_video(vf, srt_files)
if sf:
out = os.path.splitext(sf)[0] + '-synced.srt'
if os.path.isfile(out):
print(f" Skipping {vf} - {os.path.basename(out)} already exists.")
else:
pairs.append((vf, sf, out))
else:
unmatched.append(vf)
if not pairs:
print("No unprocessed pairs found.")
if unmatched:
print("Videos with no matching SRT:")
for v in unmatched:
print(f" {v}")
return
print(f"\nFound {len(pairs)} pair(s) to process:")
for vf, sf, out in pairs:
print(f" {vf} + {sf} -> {os.path.basename(out)}")
if unmatched:
print(f"\n{len(unmatched)} video(s) with no matching SRT (skipped):")
for v in unmatched:
print(f" {v}")
if input("\nProceed? [Y/n]: ").strip().lower() not in ('', 'y'):
print("Cancelled.")
return
ok_count, fail_count, failed = 0, 0, []
for vf, sf, out in pairs:
print(f"\n{'='*60}")
print(f" Video : {vf}")
print(f" SRT : {sf}")
print(f" Output: {os.path.basename(out)}")
if sync_single(vf, sf, out, ffsubsync_ok, whisper_ok, interactive=False):
ok_count += 1
else:
fail_count += 1
failed.append(vf)
print(f"\n{'='*60}")
print(f"Batch complete: {ok_count} synced, {fail_count} failed.")
if failed:
print("Run Mode 2 manually on these:")
for v in failed:
print(f" {v}")
# ---------- TMDB episode lookup + rename -------------------------------------
def tmdb_get(path, params, api_key):
params = dict(params) # don't mutate caller's dict
params['api_key'] = api_key
url = f"https://api.themoviedb.org/3{path}?{urllib.parse.urlencode(params)}"
req = urllib.request.Request(url, headers={'Accept-Encoding': 'gzip, deflate'})
try:
with urllib.request.urlopen(req, timeout=10) as r:
raw = r.read()
if raw[:2] == b'\x1f\x8b':
import gzip
raw = gzip.decompress(raw)
return json.loads(raw.decode('utf-8'))
except Exception as e:
print(f" TMDB error: {e}")
return None
def _get_tmdb_key():
key = TMDB_API_KEY.strip()
if not key:
print(" Get a free key at https://www.themoviedb.org/settings/api")
key = input(" Enter TMDB API key: ").strip()
if not key:
print(" No key - skipping.")
return None
return key
def tmdb_pick_show(show_name, key):
"""Search TMDB for show_name and let the user pick. Returns (show_id, canonical) or (None, None)."""
data = tmdb_get('/search/tv', {'query': show_name, 'page': 1}, key)
if not data or not data.get('results'):
print(" No results found.")
return None, None
results = data['results'][:6]
if len(results) > 1:
print(" Multiple results:")
for i, r in enumerate(results, 1):
year = r.get('first_air_date', '')[:4]
print(f" {i}: {r['name']} ({year})")
choice = input(" Choose [1]: ").strip()
idx = (int(choice)-1) if choice.isdigit() and 1 <= int(choice) <= len(results) else 0
else:
idx = 0
return results[idx]['id'], results[idx]['name']
def tmdb_find_episode_by_title(show_id, ep_title, key):
"""
Scan every season of show_id on TMDB looking for an episode whose title
matches ep_title (case-insensitive). Returns (season, episode_number) or (None, None).
"""
show_data = tmdb_get(f'/tv/{show_id}', {}, key)
if not show_data:
return None, None
n_seasons = show_data.get('number_of_seasons', 0)
target = ep_title.strip().lower()
for s in range(1, n_seasons + 1):
season_data = tmdb_get(f'/tv/{show_id}/season/{s}', {}, key)
if not season_data:
continue
for ep in season_data.get('episodes', []):
if ep.get('name', '').strip().lower() == target:
return s, ep['episode_number']
return None, None
def safe_filename(s):
return re.sub(r'[<>:"/\\|?*]', '', s).strip()
def find_matching_srt(video_path):
base = os.path.splitext(video_path)[0]
dirpath = os.path.dirname(video_path) or '.'
for suffix in ('', '-synced', '-offset', '-whisper'):
c = base + suffix + '.srt'
if os.path.isfile(c):
return c
_, ep_code = extract_show_info(video_path)
if ep_code:
for f in os.listdir(dirpath):
if f.lower().endswith('.srt') and ep_code.lower() in f.lower():
return os.path.join(dirpath, f)
return None
def do_rename(filepath, new_base):
ext = os.path.splitext(filepath)[1]
dirpath = os.path.dirname(filepath) or '.'
new_path = os.path.join(dirpath, new_base + ext)
if os.path.abspath(filepath) == os.path.abspath(new_path):
print(" Already named correctly.")
return new_path
try:
os.rename(filepath, new_path)
print(f" -> {os.path.basename(new_path)}")
return new_path
except Exception as e:
print(f" Rename failed: {e}")
return filepath
def _tmdb_rename(video_path, show_name, episode_code, srt_path):
"""Core TMDB lookup + rename. show_name / episode_code may be empty strings."""
if not episode_code:
print(" No SxxExx found in filename, SRT, or directory path - skipping.")
return
m = re.match(r'S(\d+)E(\d+)', episode_code, re.IGNORECASE)
if not m:
return
season, episode = int(m.group(1)), int(m.group(2))
if not show_name:
show_name = input(" Could not detect show name. Enter show name: ").strip()
if not show_name:
print(" No show name - skipping.")
return
key = _get_tmdb_key()
if not key:
return
print(f" Searching TMDB for '{show_name}'...")
show_id, canonical = tmdb_pick_show(show_name, key)
if not show_id:
return
ep_data = tmdb_get(f'/tv/{show_id}/season/{season}/episode/{episode}', {}, key)
if ep_data and 'name' in ep_data:
new_base = (f"{safe_filename(canonical)} - "
f"S{season:02d}E{episode:02d} - {safe_filename(ep_data['name'])}")
else:
print(" Episode title not found - using show name + SxxExx only.")
new_base = f"{safe_filename(canonical)} - S{season:02d}E{episode:02d}"
_confirm_and_rename(video_path, new_base, srt_path)
def _confirm_and_rename(video_path, new_base, srt_path):
ext = os.path.splitext(video_path)[1]
print(f"\n New name: {new_base}{ext}")
if srt_path:
print(f" SRT : {new_base}.srt")
if input(" Rename? [Y/n]: ").strip().lower() not in ('', 'y'):
print(" Skipped.")
return
do_rename(video_path, new_base)
if srt_path:
do_rename(srt_path, new_base)
def offer_rename(video_path):
if input("\nLook up episode title on TMDB and rename files? [y/N]: ").strip().lower() != 'y':
return
srt_path = find_matching_srt(video_path)
extra = [srt_path] if srt_path else []
show_name, ep_code = extract_show_info(video_path, extra_paths=extra)
print(f" Show : {show_name or '(not detected)'}")
print(f" Episode: {ep_code or '(not detected)'}")
_tmdb_rename(video_path, show_name, ep_code, srt_path)
def rename_mode(video_path, ocr_ok, _method=None):
srt_path = find_matching_srt(video_path)
extra = [srt_path] if srt_path else []
show_name, ep_code = extract_show_info(video_path, extra_paths=extra)
print(f"\n Show : {show_name or '(not detected)'}")
print(f" Episode: {ep_code or '(not detected)'}")
if _method is None:
print("\n How to find the episode title?")
print(" 1: TMDB lookup - search by show name + SxxExx [default]")
if ocr_ok:
print(" 2: Scan video - OCR the first 3 min for a title card")
choice = input(" Choose [1]: ").strip() or '1'
else:
choice = _method
if choice == '2' and ocr_ok:
candidates = scan_title_card(video_path)
if not candidates:
print(" No title candidates found - falling back to TMDB.")
else:
top = candidates[:20]
print(f"\n Candidates (sorted by how many frames they appeared in):")
for i, (text, count, _ts) in enumerate(top, 1):
print(f" {i}: {text} ({count} frame{'s' if count != 1 else ''})")
print("\n Enter a number to select, p<N> to preview that frame, "
"or Enter to fall back to TMDB.")
ep_title = None
while True:
sel = input(" > ").strip()
if not sel:
break
pm = re.match(r'^[pP](\d+)$', sel)
if pm:
pidx = int(pm.group(1))
if 1 <= pidx <= len(top):
_preview_frame(video_path, top[pidx - 1][2])
else:
print(f" Choose 1{len(top)}.")
continue
if sel.isdigit() and 1 <= int(sel) <= len(top):
ep_title = top[int(sel) - 1][0]
break
print(f" Enter a number (1{len(top)}), p<N> to preview, or Enter to skip.")
if ep_title:
if not show_name:
show_name = input(" Enter show name: ").strip()
if not show_name:
print(" No show name - skipping.")
return
key = _get_tmdb_key()
if not key:
return
print(f" Searching TMDB for '{show_name}' / episode '{ep_title}'...")
show_id, canonical = tmdb_pick_show(show_name, key)
if show_id:
season, ep_num = tmdb_find_episode_by_title(show_id, ep_title, key)
if season and ep_num:
new_base = (f"{safe_filename(canonical)} - "
f"S{season:02d}E{ep_num:02d} - {safe_filename(ep_title)}")
_confirm_and_rename(video_path, new_base, srt_path)
return
print(" Episode title not found on TMDB.")
if ep_code:
m = re.match(r'S(\d+)E(\d+)', ep_code, re.IGNORECASE)
if m:
season, ep_num = int(m.group(1)), int(m.group(2))
new_base = (f"{safe_filename(canonical or show_name)} - "
f"S{season:02d}E{ep_num:02d} - {safe_filename(ep_title)}")
_confirm_and_rename(video_path, new_base, srt_path)
return
print(" No episode code available either - skipping.")
return
# Default: TMDB lookup
_tmdb_rename(video_path, show_name, ep_code, srt_path)
def _rename_one_batch(video_path, srt_path, show_id, canonical, key, auto):
"""Rename one file within a batch. Returns True if renamed/confirmed, False if skipped."""
extra = [srt_path] if srt_path else []
_, ep_code = extract_show_info(video_path, extra_paths=extra)
if not ep_code:
print(f" {os.path.basename(video_path)}: no SxxExx found - skipping.")
return False
m = re.match(r'S(\d+)E(\d+)', ep_code, re.IGNORECASE)
if not m:
print(f" {os.path.basename(video_path)}: cannot parse {ep_code} - skipping.")
return False
season, ep_num = int(m.group(1)), int(m.group(2))
ep_data = tmdb_get(f'/tv/{show_id}/season/{season}/episode/{ep_num}', {}, key)
if ep_data and 'name' in ep_data:
new_base = (f"{safe_filename(canonical)} - "
f"S{season:02d}E{ep_num:02d} - {safe_filename(ep_data['name'])}")
else:
print(f" {os.path.basename(video_path)}: episode title not found - using SxxExx only.")
new_base = f"{safe_filename(canonical)} - S{season:02d}E{ep_num:02d}"
ext = os.path.splitext(video_path)[1]
print(f" {os.path.basename(video_path)}")
print(f" -> {new_base}{ext}")
if auto:
do_rename(video_path, new_base)
if srt_path:
do_rename(srt_path, new_base)
else:
if input(" Rename? [Y/n]: ").strip().lower() in ('', 'y'):
do_rename(video_path, new_base)
if srt_path:
do_rename(srt_path, new_base)
else:
print(" Skipped.")
return True
def _next_file_prompt(vid_files, idx, allow_auto=False):
"""
After processing vid_files[idx], ask what to do next.
Returns (next_index, go_auto). next_index is None to stop.
Enter = next file, 0 = stop, a = auto rest (if allow_auto), N = jump.
"""
next_idx = idx + 1
if next_idx >= len(vid_files):
print(" No more files.")
return None, False
print(f"\n Next: {os.path.basename(vid_files[next_idx])}")
auto_hint = " [a] auto rest | " if allow_auto else " "
print(f"{auto_hint}[Enter] continue | [0] stop | [1-{len(vid_files)}] jump to file")
ans = input(" > ").strip().lower()
if ans == '0':
return None, False
if ans == 'a' and allow_auto:
return next_idx, True
if ans == '':
return next_idx, False
if ans.isdigit() and 1 <= int(ans) <= len(vid_files):
return int(ans) - 1, False
return next_idx, False
def rename_tmdb_loop():
print("\n Video files in current directory:")
vid_files = list_files(VIDEO_EXTS, "video")
if not vid_files:
print(" No video files found.")
return
video = pick_file(vid_files, " Choose starting file")
if not video or not os.path.isfile(video):
print(" No valid video selected.")
return
srt0 = find_matching_srt(video)
extra0 = [srt0] if srt0 else []
show_name, _ = extract_show_info(video, extra_paths=extra0)
if not show_name:
show_name = input(" Could not detect show name. Enter show name: ").strip()
if not show_name:
return
key = _get_tmdb_key()
if not key:
return
print(f" Searching TMDB for '{show_name}'...")
show_id, canonical = tmdb_pick_show(show_name, key)
if not show_id:
return
print(f" Show: {canonical}\n")
idx = vid_files.index(video) if video in vid_files else 0
auto = False
while True:
vf = vid_files[idx]
srt = find_matching_srt(vf)
_rename_one_batch(vf, srt, show_id, canonical, key, auto=auto)
if auto:
idx += 1
if idx >= len(vid_files):
print(" No more files.")
break
else:
idx, auto = _next_file_prompt(vid_files, idx, allow_auto=True)
if idx is None:
break
def rename_scan_loop(ocr_ok):
print("\n Video files in current directory:")
vid_files = list_files(VIDEO_EXTS, "video")
if not vid_files:
print(" No video files found.")
return
video = pick_file(vid_files, " Choose starting file")
if not video or not os.path.isfile(video):
print(" No valid video selected.")
return
idx = vid_files.index(video) if video in vid_files else 0
while True:
rename_mode(vid_files[idx], ocr_ok, _method='2')
idx, _ = _next_file_prompt(vid_files, idx)
if idx is None:
break
def rename_menu(ocr_ok):
while True:
print("\n RENAME")
print(" 1: TMDB lookup [default]")
if ocr_ok:
print(" 2: Scan video for title card")
print(" 0: Back to main menu")
valid = ('0', '1', '2') if ocr_ok else ('0', '1')
while True:
choice = input(" Choose [1]: ").strip() or '1'
if choice in valid:
break
print(f" Please enter {'0, 1 or 2' if ocr_ok else '0 or 1'}.")
if choice == '0':
break
elif choice == '1':
rename_tmdb_loop()
elif choice == '2':
rename_scan_loop(ocr_ok)
# ---------- Subtitle extraction ----------------------------------------------
def probe_subtitle_streams(video_path):
"""Return list of subtitle stream dicts from ffprobe."""
try:
r = subprocess.run([
'ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_streams', '-select_streams', 's', video_path
], capture_output=True, text=True, timeout=30)
return json.loads(r.stdout).get('streams', [])
except Exception:
return []
def _sub_out_path(video_path, lang=''):
base = os.path.splitext(video_path)[0]
return f"{base}.{lang}.srt" if lang else f"{base}.srt"
def _extract_text_track(video_path, stream_index, out_path):
r = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video_path, '-map', f'0:{stream_index}',
'-c:s', 'srt', '-y', out_path
], timeout=300)
return r.returncode == 0 and os.path.isfile(out_path)
def _extract_cc(video_path, out_path, cce_cmd):
print(" Running ccextractor...")
r = subprocess.run([cce_cmd, video_path, '-o', out_path], timeout=600)
return r.returncode == 0 and os.path.isfile(out_path)
def _extract_pgs(video_path, stream_index, out_path):
"""Extract Blu-ray PGS subtitle track → SRT via pgsreader + easyocr."""
try:
import easyocr
from pgsreader import PGSReader
import numpy as np
from PIL import Image as _PILImage
except ImportError as e:
print(f" Missing dependency: {e}")
return False
import tempfile
fd, sup_path = tempfile.mkstemp(suffix='.sup')
os.close(fd)
try:
print(" Extracting PGS stream...")
r = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video_path, '-map', f'0:{stream_index}',
'-c:s', 'copy', '-y', sup_path
], timeout=300)
if r.returncode != 0:
print(" ffmpeg extraction failed.")
return False
print(" Reading PGS display sets...")
pgs = PGSReader(sup_path)
reader = easyocr.Reader(['en'], verbose=False)
entries = []
pending = None
for ds in pgs.displaySets:
ts_s = ds.pcs.presentation_timestamp / 90000.0
if ds.has_image:
img = ds.to_image().convert('RGB')
results = reader.readtext(np.array(img), detail=0, paragraph=True)
text = ' '.join(results).strip()
if pending:
entries.append(pending)
pending = [ts_s, None, text] if text else None
else:
if pending:
pending[1] = ts_s
entries.append(pending)
pending = None
if pending:
pending[1] = pending[0] + 3.0
entries.append(pending)
print(f" Writing {len(entries)} subtitle entries...")
with open(out_path, 'w', encoding='utf-8') as f:
for i, (start, end, text) in enumerate(entries, 1):
f.write(f"{i}\n")
f.write(f"{seconds_to_srt(start)} --> {seconds_to_srt(end)}\n")
f.write(f"{text}\n\n")
return True
finally:
try:
os.unlink(sup_path)
except Exception:
pass
def _extract_vobsub(video_path, stream_index, out_path):
"""Extract DVD VOB subtitle track → SRT via vobsub2srt."""
if not ensure_vobsub2srt():
return False
import tempfile, shutil
with tempfile.TemporaryDirectory() as tmpdir:
sub_base = os.path.join(tmpdir, 'subs')
r = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video_path, '-map', f'0:{stream_index}',
'-c:s', 'copy', '-y', sub_base + '.sub'
], timeout=300)
if r.returncode != 0:
print(" ffmpeg extraction failed.")
return False
r2 = subprocess.run(['vobsub2srt', sub_base], timeout=300)
if r2.returncode == 0 and os.path.isfile(sub_base + '.srt'):
shutil.copy(sub_base + '.srt', out_path)
return True
print(" vobsub2srt conversion failed.")
return False
def extract_subs_mode():
print("\n Video files in current directory:")
vid_files = list_files(VIDEO_EXTS, "video")
if vid_files:
video = pick_file(vid_files, " Choose video by number or filename")
else:
video = input(" Enter path to video file (0 to cancel): ").strip()
if video == '0':
return
if not video or not os.path.isfile(video):
print(" No valid video selected.")
return
video = _offer_mp4_remux(video)
streams = probe_subtitle_streams(video)
# Build menu: numbered subtitle tracks + CC option
options = []
if streams:
print("\n Subtitle tracks found:")
for s in streams:
codec = s.get('codec_name', 'unknown')
idx = s.get('index', '?')
lang = s.get('tags', {}).get('language', '')
title = s.get('tags', {}).get('title', '')
label = codec
if lang: label += f" [{lang}]"
if title: label += f" — {title}"
if codec in _TEXT_SUB_CODECS:
label += " (text, instant)"
elif codec in _IMAGE_SUB_CODECS:
label += " (image, needs OCR)"
print(f" {len(options)+1}: {label}")
options.append(('track', s))
else:
print("\n No subtitle tracks found in file.")
print(f" {len(options)+1}: Closed captions from video stream (ccextractor)")
options.append(('cc', None))
print(" 0: Cancel")
while True:
sel = input(" Choose: ").strip()
if sel == '0':
return
if sel.isdigit() and 1 <= int(sel) <= len(options):
break
print(f" Enter 1-{len(options)} or 0.")
kind, stream = options[int(sel) - 1]
base = os.path.splitext(video)[0]
if kind == 'cc':
cce = ensure_ccextractor()
if not cce:
return
out = _sub_out_path(video, 'cc')
if _extract_cc(video, out, cce):
print(f" Done: {os.path.basename(out)}")
else:
print(" ccextractor found no CC in this file.")
return
codec = stream.get('codec_name', '')
stream_idx = stream.get('index')
lang = stream.get('tags', {}).get('language', '')
out = _sub_out_path(video, lang)
if codec in _TEXT_SUB_CODECS:
print(f" Extracting text track {stream_idx}{os.path.basename(out)} ...")
if _extract_text_track(video, stream_idx, out):
print(f" Done: {os.path.basename(out)}")
else:
print(" Extraction failed.")
elif codec in _IMAGE_SUB_CODECS:
print(f"\n '{codec}' is an image-based subtitle format.")
print(" 1: Native format - extract as .sup / .sub (perfect quality, instant) [default]")
print(" 2: OCR to SRT - read text via OCR (editable, some quality loss)")
fmt = input(" Choose [1]: ").strip() or '1'
if fmt != '2':
# Native extraction — no OCR, perfect quality
if codec in {'hdmv_pgs_subtitle', 'pgssub'}:
native_out = base + (f'.{lang}' if lang else '') + '.sup'
else:
native_out = base + (f'.{lang}' if lang else '') + '.sub'
print(f" Extracting → {os.path.basename(native_out)} ...")
r = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video, '-map', f'0:{stream_idx}',
'-c:s', 'copy', '-y', native_out
], timeout=300)
if r.returncode == 0 and os.path.isfile(native_out):
print(f" Done: {os.path.basename(native_out)}")
else:
print(" Extraction failed.")
elif codec in {'hdmv_pgs_subtitle', 'pgssub'}:
if not ensure_pgsreader() or not ensure_easyocr():
return
print(f" Extracting PGS → {os.path.basename(out)} (OCR, may take a while)...")
if _extract_pgs(video, stream_idx, out):
print(f" Done: {os.path.basename(out)}")
else:
print(" PGS extraction failed.")
else:
print(f" Extracting DVD/DVB subtitle → {os.path.basename(out)} ...")
if not _extract_vobsub(video, stream_idx, out):
print(" Could not extract automatically.")
else:
print(f" Codec '{codec}' not yet supported for direct extraction.")
print(" Try: ffmpeg -i video -map 0:s:N -c:s srt output.srt")
# ---------- MP4 → MKV remux --------------------------------------------------
_LANG_ISO1_TO_639_2 = {
'en': 'eng', 'fr': 'fre', 'de': 'ger', 'es': 'spa', 'it': 'ita',
'pt': 'por', 'nl': 'dut', 'ru': 'rus', 'ja': 'jpn', 'zh': 'chi',
'ko': 'kor', 'ar': 'ara', 'pl': 'pol', 'sv': 'swe', 'no': 'nor',
'da': 'dan', 'fi': 'fin', 'cs': 'cze', 'tr': 'tur', 'hu': 'hun',
}
_SUB_EXTS = ('.srt', '.ass', '.ssa', '.vtt', '.sup', '.sub')
def _do_remux(video_path, out_path):
"""Stream-copy video_path → out_path (MKV). Returns True on success."""
import shutil
use_mkvmerge = bool(shutil.which('mkvmerge'))
if use_mkvmerge:
print(f" mkvmerge: {os.path.basename(video_path)}{os.path.basename(out_path)}")
cmd = ['mkvmerge', '-o', out_path, video_path]
else:
print(f" ffmpeg stream copy (mkvmerge not found): {os.path.basename(video_path)}{os.path.basename(out_path)}")
cmd = ['ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video_path, '-c', 'copy', '-y', out_path]
try:
r = subprocess.run(cmd, timeout=600)
except subprocess.TimeoutExpired:
print(" Timed out.")
return False
if r.returncode == 0 and os.path.isfile(out_path):
return True
print(" Remux failed.")
if os.path.exists(out_path):
os.remove(out_path)
return False
def _offer_mp4_remux(video_path):
"""If video_path is an MP4, offer (default yes) to remux to MKV first.
Returns the path to use going forward (MKV on success, original otherwise)."""
if not video_path.lower().endswith('.mp4'):
return video_path
base = os.path.splitext(video_path)[0]
mkv_out = base + '.mkv'
print(f"\n '{os.path.basename(video_path)}' is an MP4.")
print(" MKV handles all subtitle types; MP4 only supports mov_text (SRT).")
if os.path.exists(mkv_out):
print(f" MKV already exists: {os.path.basename(mkv_out)}")
resp = input(" Use existing MKV? [Y/n]: ").strip().lower()
if resp != 'n':
return mkv_out
return video_path
resp = input(" Convert to MKV now (lossless)? [Y/n]: ").strip().lower()
if resp == 'n':
return video_path
if _do_remux(video_path, mkv_out):
in_mb = os.path.getsize(video_path) / 1_048_576
out_mb = os.path.getsize(mkv_out) / 1_048_576
print(f" Done: {os.path.basename(mkv_out)} ({in_mb:.0f} MB → {out_mb:.0f} MB)")
resp = input(" Delete original MP4? [y/N]: ").strip().lower()
if resp == 'y':
os.remove(video_path)
print(f" Deleted: {os.path.basename(video_path)}")
return mkv_out
return video_path
def _detect_lang_tag(sub_path):
"""Guess ISO 639-2 language tag from filename stem (e.g. video.en.srt → eng)."""
stem = os.path.splitext(os.path.basename(sub_path))[0]
parts = stem.rsplit('.', 1)
if len(parts) == 2:
code = parts[1].lower()
if code in _LANG_ISO1_TO_639_2:
return _LANG_ISO1_TO_639_2[code]
if len(code) == 3 and code.isalpha():
return code
return ''
def remux_mp4_to_mkv():
"""Mode 6: remux MP4 (or any container) to MKV — stream copy, no re-encode."""
print("\n MP4 → MKV")
all_vid = list_files(VIDEO_EXTS, "video")
mp4_files = [f for f in all_vid if f.lower().endswith('.mp4')]
if mp4_files:
candidates = mp4_files
else:
print(" (no .mp4 found — showing all video files)")
candidates = all_vid
if candidates:
video = pick_file(candidates, " Choose file to remux (0 to cancel)")
else:
video = input(" Enter path to video file (0 to cancel): ").strip()
if video == '0':
return
if not video:
return
if not os.path.isfile(video):
print(" File not found.")
return
base = os.path.splitext(video)[0]
out = base + '.mkv'
if os.path.exists(out):
print(f" Output already exists: {os.path.basename(out)}")
resp = input(" Overwrite? [y/N]: ").strip().lower()
if resp != 'y':
print(" Cancelled.")
return
if _do_remux(video, out):
in_mb = os.path.getsize(video) / 1_048_576
out_mb = os.path.getsize(out) / 1_048_576
print(f" Done: {os.path.basename(out)} ({in_mb:.0f} MB → {out_mb:.0f} MB)")
resp = input(" Delete original? [y/N]: ").strip().lower()
if resp == 'y':
os.remove(video)
print(f" Deleted: {os.path.basename(video)}")
def embed_subs_mode():
"""Mode 7: soft-mux a subtitle file into a video using mkvmerge."""
if not ensure_mkvtoolnix():
return
# --- pick video ---
print("\n EMBED: Video files in current directory:")
vid_files = list_files(VIDEO_EXTS, "video")
if vid_files:
video = pick_file(vid_files, " Choose video (0 to cancel)")
else:
video = input(" Enter path to video file (0 to cancel): ").strip()
if video == '0':
return
if not video or not os.path.isfile(video):
print(" No valid video selected.")
return
# offer MP4 → MKV before anything else
video = _offer_mp4_remux(video)
# --- pick subtitle file ---
sub_files = sorted(
f for f in os.listdir('.')
if f.lower().endswith(_SUB_EXTS) and not f.endswith('.idx')
)
if sub_files:
print("\n Subtitle files in current directory:")
for i, f in enumerate(sub_files, 1):
print(f" {i}: {f}")
sub = pick_file(sub_files, " Choose subtitle file (0 to cancel)")
else:
sub = input(" Enter path to subtitle file (0 to cancel): ").strip()
if sub == '0':
return
if not sub or not os.path.isfile(sub):
print(" No valid subtitle file selected.")
return
# --- language tag ---
detected = _detect_lang_tag(sub)
if detected:
print(f" Detected language tag: {detected}")
resp = input(f" Use '{detected}'? [Y/n]: ").strip().lower()
lang = detected if resp != 'n' else ''
else:
lang = ''
if not lang:
lang = input(" Enter ISO 639-2 language tag (e.g. eng, fre) or Enter to skip: ").strip().lower()
# --- build mkvmerge command ---
base = os.path.splitext(video)[0]
tmp_out = base + '._embed_tmp.mkv'
cmd = ['mkvmerge', '-o', tmp_out, video]
if lang:
cmd += ['--language', f'0:{lang}']
cmd.append(sub)
print(f"\n Embedding {os.path.basename(sub)}{os.path.basename(video)} ...")
try:
r = subprocess.run(cmd, timeout=600)
except subprocess.TimeoutExpired:
print(" Timed out.")
return
if r.returncode not in (0, 1) or not os.path.isfile(tmp_out):
# mkvmerge returns 1 for warnings (still produces output)
print(" mkvmerge failed.")
if os.path.exists(tmp_out):
os.remove(tmp_out)
return
# replace original with muxed file
os.replace(tmp_out, video)
print(f" Done: subtitle embedded into {os.path.basename(video)}")
resp = input(" Delete separate subtitle file? [y/N]: ").strip().lower()
if resp == 'y':
os.remove(sub)
# also remove .idx if present alongside .sub
idx = os.path.splitext(sub)[0] + '.idx'
if os.path.exists(idx):
os.remove(idx)
print(f" Deleted: {os.path.basename(sub)}")
def _extract_all_noninteractive(video_path):
"""--extract-all: dump every subtitle track + CC without prompting."""
if not os.path.isfile(video_path):
print(f"File not found: {video_path}", file=sys.stderr)
sys.exit(1)
print(f"Extracting all subtitles from: {video_path}")
streams = probe_subtitle_streams(video_path)
extracted = 0
for s in streams:
codec = s.get('codec_name', '')
stream_idx = s.get('index')
lang = s.get('tags', {}).get('language', '')
out = _sub_out_path(video_path, lang or str(stream_idx))
if codec in _TEXT_SUB_CODECS:
if _extract_text_track(video_path, stream_idx, out):
print(f" Extracted text track {stream_idx}{os.path.basename(out)}")
extracted += 1
elif codec in {'hdmv_pgs_subtitle', 'pgssub'}:
native_out = os.path.splitext(video_path)[0] + (f'.{lang}' if lang else f'.{stream_idx}') + '.sup'
r = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video_path, '-map', f'0:{stream_idx}', '-c:s', 'copy', '-y', native_out
], timeout=300)
if r.returncode == 0 and os.path.isfile(native_out):
print(f" Extracted PGS track {stream_idx}{os.path.basename(native_out)}")
extracted += 1
elif codec in _IMAGE_SUB_CODECS:
native_out = os.path.splitext(video_path)[0] + (f'.{lang}' if lang else f'.{stream_idx}') + '.sub'
r = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video_path, '-map', f'0:{stream_idx}', '-c:s', 'copy', '-y', native_out
], timeout=300)
if r.returncode == 0 and os.path.isfile(native_out):
print(f" Extracted VOB SUB track {stream_idx}{os.path.basename(native_out)}")
extracted += 1
# try ccextractor for broadcast CC
import shutil as _sh
cce = _sh.which('ccextractor') or _sh.which('ccextractorwin')
if cce:
cc_out = _sub_out_path(video_path, 'cc')
r = subprocess.run([cce, video_path, '-o', cc_out], timeout=600,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if r.returncode == 0 and os.path.isfile(cc_out):
print(f" Extracted CC → {os.path.basename(cc_out)}")
extracted += 1
print(f"Done. {extracted} track(s) extracted.")
sys.exit(0)
# ---------- Mode 8: Burnt-in subtitle OCR and removal -----------------------
def _probe_video_size(video_path):
"""Return (width, height) of the first video stream."""
try:
r = subprocess.run([
'ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_streams', '-select_streams', 'v:0', video_path
], capture_output=True, text=True, timeout=15)
s = json.loads(r.stdout)['streams'][0]
return int(s['width']), int(s['height'])
except Exception:
return 1920, 1080
def _video_duration(video_path):
try:
r = subprocess.run([
'ffprobe', '-v', 'quiet', '-show_entries', 'format=duration',
'-print_format', 'json', video_path
], capture_output=True, text=True, timeout=15)
return float(json.loads(r.stdout)['format']['duration'])
except Exception:
return 0.0
def scan_burnt_in_subs(video_path, fps=1, crop_fraction=0.28):
"""
OCR burnt-in subtitles from the bottom crop_fraction of each frame at fps.
Returns (entries, region):
entries = [(start_sec, end_sec, text), ...]
region = (x, y, w, h) estimated black-box in full-frame pixels, or None
"""
if not ensure_easyocr():
return [], None
import easyocr
width, height = _probe_video_size(video_path)
crop_y = int(height * (1.0 - crop_fraction))
crop_h = height - crop_y
duration = _video_duration(video_path)
est = int(duration * fps) if duration else '?'
print(f" Extracting frames at {fps}fps (~{est} frames, bottom {int(crop_fraction*100)}%)...")
import tempfile
with tempfile.TemporaryDirectory() as tmpdir:
frame_pat = os.path.join(tmpdir, 'f_%06d.png')
r = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error', '-i', video_path,
'-vf', f'crop={width}:{crop_h}:0:{crop_y},fps={fps}',
frame_pat
], timeout=7200)
if r.returncode != 0:
print(" Frame extraction failed.")
return [], None
frames = sorted(glob.glob(os.path.join(tmpdir, 'f_*.png')))
if not frames:
print(" No frames extracted.")
return [], None
print(f" OCR on {len(frames)} frames (first run downloads ~170 MB model)...")
reader = easyocr.Reader(['en'], verbose=False)
entries = []
current_text = None
start_time = None
all_bboxes = [] # (x1,y1,x2,y2) in full-frame pixels
for i, fp in enumerate(frames):
ts = i / fps
try:
results = reader.readtext(fp, detail=1, paragraph=False)
except Exception:
results = []
texts = []
for (bbox, text, conf) in results:
if conf < 0.35 or not text.strip():
continue
texts.append(text.strip())
bx1 = int(min(p[0] for p in bbox))
by1 = int(min(p[1] for p in bbox)) + crop_y
bx2 = int(max(p[0] for p in bbox))
by2 = int(max(p[1] for p in bbox)) + crop_y
all_bboxes.append((bx1, by1, bx2, by2))
line = postprocess_text(' '.join(texts)) if texts else ''
if line:
if line != current_text:
if current_text is not None:
entries.append((start_time, ts, current_text))
current_text = line
start_time = ts
else:
if current_text is not None:
entries.append((start_time, ts, current_text))
current_text = None
if current_text is not None and start_time is not None:
entries.append((start_time, len(frames) / fps, current_text))
entries = [(s, e, t) for s, e, t in entries if e - s >= 0.4]
region = None
if all_bboxes:
x1 = max(0, min(b[0] for b in all_bboxes) - 20)
y1 = max(0, min(b[1] for b in all_bboxes) - 15)
x2 = min(width, max(b[2] for b in all_bboxes) + 20)
y2 = min(height,max(b[3] for b in all_bboxes) + 15)
region = (x1, y1, x2 - x1, y2 - y1)
return entries, region
def remove_burnt_in_region(video_path, x, y, w, h, output_path):
"""
Remove a rectangular region using ffmpeg delogo filter.
Re-encodes video; audio and subtitle tracks are stream-copied.
Limitation: pixels under the box are gone — delogo blends from
surrounding pixels. Simple/static backgrounds look good; busy action
scenes will show visible blending artifacts.
"""
print(f" Applying delogo: x={x} y={y} w={w} h={h}")
print(" Re-encoding video (libx264 CRF 18) — this will take a while...")
cmd = [
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video_path,
'-vf', f'delogo=x={x}:y={y}:w={w}:h={h}:show=0',
'-c:v', 'libx264', '-crf', '18', '-preset', 'medium',
'-c:a', 'copy', '-c:s', 'copy',
'-y', output_path
]
try:
r = subprocess.run(cmd, timeout=7200)
return r.returncode == 0 and os.path.isfile(output_path)
except subprocess.TimeoutExpired:
print(" Timed out.")
return False
def _burnt_in_two_file_sync():
"""
Two-file workflow: OCR burnt-in subs from a CC copy, then sync the
resulting SRT against a clean (no burnt-in subs) copy of the same video.
Useful when you have both the CC broadcast version and a clean retail copy.
"""
print("\n TWO-FILE SYNC")
print(" Step 1 of 2 — pick the video WITH burnt-in subtitles (the CC copy):")
vid_files = list_files(VIDEO_EXTS, "video")
if vid_files:
cc_video = pick_file(vid_files, " Choose CC video (0 to cancel)")
else:
cc_video = input(" Path to CC video (0 to cancel): ").strip()
if cc_video == '0':
return
if not cc_video or not os.path.isfile(cc_video):
print(" No valid file selected.")
return
print("\n Step 2 of 2 — pick the CLEAN video (no burnt-in subs):")
if vid_files:
remaining = [f for f in vid_files if f != cc_video]
if remaining:
for i, f in enumerate(remaining, 1):
print(f" {i}: {f}")
clean_video = pick_file(remaining, " Choose clean video (0 to cancel)")
else:
clean_video = input(" Path to clean video (0 to cancel): ").strip()
if clean_video == '0':
return
else:
clean_video = input(" Path to clean video (0 to cancel): ").strip()
if clean_video == '0':
return
if not clean_video or not os.path.isfile(clean_video):
print(" No valid file selected.")
return
print("\n Scan rate (affects timing accuracy and speed):")
print(" 1: 1 fps - ±1s accuracy, fast [default]")
print(" 2: 2 fps - ±0.5s accuracy, slower")
fps = 2 if (input(" Choose [1]: ").strip() == '2') else 1
# ── Step A: OCR the CC video ──────────────────────────────────────────────
print(f"\n Scanning '{os.path.basename(cc_video)}' for burnt-in subtitles...")
entries, _region = scan_burnt_in_subs(cc_video, fps=fps)
if not entries:
print(" No subtitles detected in the CC video. Aborting.")
return
print(f" Detected {len(entries)} subtitle entries.")
import tempfile
fd, raw_srt = tempfile.mkstemp(suffix='-burntocr-raw.srt')
os.close(fd)
with open(raw_srt, 'w', encoding='utf-8') as f:
for i, (s, e, t) in enumerate(entries, 1):
f.write(f"{i}\n{seconds_to_srt(s)} --> {seconds_to_srt(e)}\n{t}\n\n")
# ── Step B: sync the raw SRT against the clean video ─────────────────────
base = os.path.splitext(clean_video)[0]
out_srt = f"{base}-burntocr-synced.srt"
print(f"\n Syncing OCR'd SRT against '{os.path.basename(clean_video)}'...")
if not ensure_ffsubsync():
# No ffsubsync — just write the raw SRT alongside the clean video
import shutil
shutil.copy(raw_srt, out_srt)
os.remove(raw_srt)
print(f" ffsubsync not available — wrote unsynced SRT: {os.path.basename(out_srt)}")
print(" You can sync it later with Mode 1 (SYNC).")
return
ok, _offset = sync_with_ffsubsync(clean_video, raw_srt, out_srt)
os.remove(raw_srt)
if ok and os.path.isfile(out_srt):
kb = os.path.getsize(out_srt) / 1024
print(f"\n Done: {os.path.basename(out_srt)} ({kb:.0f} KB, {len(entries)} entries)")
print(" This SRT is timed to the clean video and ready to use.")
# Offer manual fine-tune: OCR timing is at best ±0.5s so a nudge may help
print("\n Fine-tune timing?")
print(" Subtitle text appears BEFORE you hear it → use a POSITIVE number (+0.5)")
print(" You hear the sound BEFORE the text appears → use a NEGATIVE number (-0.5)")
print(" Enter to skip.")
while True:
resp = input(" Offset seconds [Enter to skip]: ").strip()
if resp == '' or resp == '0':
break
offset = parse_offset(resp)
if offset is None:
print(" Invalid — enter a number like 0.5 or -1.2.")
continue
import shutil
tmp = out_srt + '.bak'
shutil.copy(out_srt, tmp)
shift_srt(tmp, out_srt, offset)
os.remove(tmp)
print(f" Applied {offset:+.3f}s offset to {os.path.basename(out_srt)}")
again = input(" Try another offset? [y/N]: ").strip().lower()
if again != 'y':
break
# Load from the current (already-shifted) file each time — offsets stack
else:
print(" Sync failed. The raw OCR SRT has been discarded.")
print(" Tip: re-run with '1: Transcribe only' on the CC video and sync manually.")
def burnt_in_subs_mode():
"""Mode 8: OCR burnt-in subtitles → SRT and/or remove them from video."""
print("\n BURNSUBS — what would you like to do?")
print(" 1: Transcribe only - OCR burnt-in subs → SRT")
print(" 2: Remove only - erase subtitle band from video (re-encodes)")
print(" 3: Both - transcribe then remove [default]")
print(" 4: Two-file sync - OCR subs from CC copy, sync SRT to clean copy")
print(" 0: Cancel")
while True:
ch = input(" Choose [3]: ").strip() or '3'
if ch in ('0', '1', '2', '3', '4'):
break
print(" Enter 0-4.")
if ch == '0':
return
# ── Option 4: two-file workflow ──────────────────────────────────────────
if ch == '4':
_burnt_in_two_file_sync()
return
# ── Options 1-3: single-file workflow ────────────────────────────────────
print("\n BURNSUBS: Video files in current directory:")
vid_files = list_files(VIDEO_EXTS, "video")
if vid_files:
video = pick_file(vid_files, " Choose video (0 to cancel)")
else:
video = input(" Enter path to video file (0 to cancel): ").strip()
if video == '0':
return
if not video or not os.path.isfile(video):
print(" No valid video selected.")
return
video = _offer_mp4_remux(video)
do_ocr = ch in ('1', '3')
do_remove = ch in ('2', '3')
print("\n Scan rate (affects timing accuracy and speed):")
print(" 1: 1 fps - ±1s accuracy, fast [default]")
print(" 2: 2 fps - ±0.5s accuracy, slower")
fps = 2 if (input(" Choose [1]: ").strip() == '2') else 1
region = None
srt_path = None
if do_ocr:
print(f"\n Scanning for burnt-in subtitles...")
entries, region = scan_burnt_in_subs(video, fps=fps)
if not entries:
print(" No subtitles detected.")
if do_remove and region is None:
print(" Cannot auto-detect removal region. Run transcribe pass first, or enter region manually.")
do_remove = True # fall through to manual entry below
else:
print(f" Detected {len(entries)} subtitle entries.")
base = os.path.splitext(video)[0]
srt_path = f"{base}-burntocr.srt"
with open(srt_path, 'w', encoding='utf-8') as f:
for i, (s, e, t) in enumerate(entries, 1):
f.write(f"{i}\n{seconds_to_srt(s)} --> {seconds_to_srt(e)}\n{t}\n\n")
print(f" SRT: {os.path.basename(srt_path)}")
if do_remove:
if region:
x, y, w, h = region
print(f"\n Auto-detected subtitle region: x={x} y={y} w={w} h={h}")
print(" Note: pixels under the black box cannot be recovered.")
print(" delogo blends from surrounding pixels — looks good on")
print(" simple backgrounds, may show artifacts on busy scenes.")
if input(" Adjust region? [y/N]: ").strip().lower() == 'y':
region = None
if region is None:
vw, vh = _probe_video_size(video)
print(f"\n Enter subtitle region (video is {vw}x{vh}).")
print(" Format: x y width height — e.g. for full-width bottom band: 0 920 1920 100")
while True:
raw = input(" Region (0 to cancel): ").strip()
if raw == '0':
return
try:
x, y, w, h = map(int, raw.split())
region = (x, y, w, h)
break
except ValueError:
print(" Enter four integers.")
x, y, w, h = region
base = os.path.splitext(video)[0]
ext = os.path.splitext(video)[1]
out = f"{base}-clean{ext}"
if input(f"\n Write to {os.path.basename(out)} — proceed? [Y/n]: ").strip().lower() == 'n':
return
if remove_burnt_in_region(video, x, y, w, h, out):
mb = os.path.getsize(out) / 1_048_576
print(f" Done: {os.path.basename(out)} ({mb:.0f} MB)")
if input(" Delete original? [y/N]: ").strip().lower() == 'y':
os.remove(video)
print(f" Deleted: {os.path.basename(video)}")
else:
print(" Removal failed.")
# ---------- Mode 1: Sync (with language detection + transcribe/translate) ----
_LANG_NAMES = {
'id': 'Indonesian', 'ms': 'Malay', 'fr': 'French', 'es': 'Spanish',
'de': 'German', 'it': 'Italian', 'pt': 'Portuguese', 'nl': 'Dutch',
'ru': 'Russian', 'zh-cn': 'Chinese', 'zh-tw': 'Chinese (Traditional)',
'ja': 'Japanese', 'ko': 'Korean', 'ar': 'Arabic', 'th': 'Thai',
'vi': 'Vietnamese', 'pl': 'Polish', 'sv': 'Swedish', 'no': 'Norwegian',
'da': 'Danish', 'fi': 'Finnish', 'tr': 'Turkish', 'cs': 'Czech',
'hu': 'Hungarian', 'ro': 'Romanian', 'uk': 'Ukrainian', 'tl': 'Filipino',
}
def ensure_langdetect():
try:
import langdetect # noqa: F401
return True
except ImportError:
pass
print("\nlangdetect not installed (used for subtitle language detection).")
if input(" Install it now? [Y/n]: ").strip().lower() == 'n':
return False
if not _pip_install('langdetect'):
return False
import importlib
importlib.invalidate_caches()
try:
import langdetect # noqa: F401
return True
except ImportError:
return False
def _srt_detect_language(srt_path):
"""Detect the language of an SRT file.
Returns (lang_code, lang_name) or (None, None) if detection fails.
Uses langdetect for Latin-script languages (Indonesian, Malay, French, etc.)
and falls back to Unicode character analysis for non-Latin scripts.
"""
entries = parse_srt_full(srt_path, limit=60)
if not entries:
return None, None
all_text = ' '.join(t for _, _, t in entries)
letters = [c for c in all_text if c.isalpha()]
if not letters:
return None, None
# Fast path: non-Latin scripts (CJK, Arabic, Cyrillic, etc.)
non_ascii = sum(1 for c in letters if ord(c) > 127)
if (non_ascii / len(letters)) > 0.15:
# Try langdetect for the name, fall back to 'unknown'
try:
if ensure_langdetect():
from langdetect import detect
code = detect(all_text[:2000])
return code, _LANG_NAMES.get(code, code.upper())
except Exception:
pass
return 'xx', 'non-Latin script'
# Latin-script: needs langdetect to distinguish Indonesian/Malay/English/etc.
if not ensure_langdetect():
return None, None
try:
from langdetect import detect, DetectorFactory
DetectorFactory.seed = 0 # make results deterministic
code = detect(all_text[:2000])
if code == 'en':
return 'en', 'English'
return code, _LANG_NAMES.get(code, code.upper())
except Exception:
return None, None
def split_sync_intro_show(video):
"""
Two-pass sync for series episodes with a recurring intro.
Pass 1: sync intro.srt against the video audio → correct timing for the
intro; the synced intro entries are used directly in the output.
Pass 2: extract show audio from where the intro ends, sync the show SRT
(which is treated as show-only content, starting near 00:00:00)
against that clip → offset_B, then shift timestamps to absolute
video time by adding intro_end_video.
The episode SRT should cover only the show content; it does not need
intro subtitles — those come from intro.srt.
"""
if not ensure_ffsubsync():
print(" ffsubsync is required for split sync.")
return
# --- Locate intro.srt ---
intro_srt = 'intro.srt'
if not os.path.isfile(intro_srt):
vid_dir = os.path.dirname(os.path.abspath(video))
intro_srt = os.path.join(vid_dir, 'intro.srt')
if os.path.isfile(intro_srt):
ans = input(f" Found {os.path.basename(intro_srt)} — use it as intro reference? [y/N]: ").strip().lower()
if ans != 'y':
intro_srt = ''
if not intro_srt or not os.path.isfile(intro_srt):
print(" SRT files in current directory:")
srt_candidates = list_files('.srt', 'SRT')
if not srt_candidates:
print(" No SRT files found — cannot run split sync.")
return
intro_srt = pick_file(srt_candidates, " Choose intro SRT (0 to cancel)")
if not intro_srt:
return
print(f" Intro reference: {os.path.basename(intro_srt)}")
# --- Pick show SRT (show content only, need not contain intro lines) ---
print("\n Show SRT files (show content only — intro comes from intro.srt):")
srt_files = [f for f in list_files('.srt', 'SRT') if f != os.path.basename(intro_srt)]
if srt_files:
episode_srt = pick_file(srt_files, " Choose show SRT (0 to cancel)")
else:
episode_srt = input(" Path to show SRT (0 to cancel): ").strip()
if episode_srt == '0':
return
if not episode_srt or not os.path.isfile(episode_srt):
print(" No valid SRT selected.")
return
import tempfile
# ── Pass 1: sync intro against the full video ─────────────────────────────
print(f"\n Pass 1 of 2 — syncing {os.path.basename(intro_srt)} against {os.path.basename(video)}...")
fd, intro_synced_tmp = tempfile.mkstemp(suffix='.srt')
os.close(fd)
ok1, offset_A = sync_with_ffsubsync(video, intro_srt, intro_synced_tmp)
if not ok1 or offset_A is None:
print(" Intro sync failed — cannot determine split point.")
try: os.remove(intro_synced_tmp)
except OSError: pass
return
print(f" Intro offset: {offset_A:+.3f}s")
# The synced intro entries already have correct absolute timestamps.
intro_synced_entries = parse_srt_full(intro_synced_tmp)
try: os.remove(intro_synced_tmp)
except OSError: pass
if not intro_synced_entries:
print(" Could not read synced intro SRT — aborting.")
return
intro_end_video = max(e for _, e, _ in intro_synced_entries)
print(f" Intro ends at {seconds_to_srt(intro_end_video)} in video")
print(f" Intro: {len(intro_synced_entries)} entries ready")
# Write a preview file so the user can open it and check before deciding
intro_preview = os.path.splitext(intro_srt)[0] + '-synced-preview.srt'
with open(intro_preview, 'w', encoding='utf-8') as f:
for i, (s, e, t) in enumerate(intro_synced_entries, 1):
f.write(f"{i}\n{seconds_to_srt(s)} --> {seconds_to_srt(e)}\n{t}\n\n")
print(f" Preview written: {os.path.basename(intro_preview)}")
print(" Framerate correction (if needed) was applied automatically.")
print(" Open the preview in a text editor or subtitle viewer to check timing.")
input(" Press Enter when ready to continue...")
# Optional manual nudge on the intro before combining
print("\n Intro timing fine-tune (or Enter to skip):")
print(" Subtitle text appears BEFORE you hear it → positive number (+3.0)")
print(" You hear the sound BEFORE the text appears → negative number (-3.0)")
while True:
resp = input(" Intro offset seconds [Enter to skip]: ").strip()
if resp == '' or resp == '0':
break
extra = parse_offset(resp)
if extra is None:
print(" Invalid — enter a number like 3.0 or -1.5.")
continue
intro_synced_entries = [
(max(0.0, s + extra), max(0.0, e + extra), t)
for s, e, t in intro_synced_entries
]
intro_end_video = max(e for _, e, _ in intro_synced_entries)
with open(intro_preview, 'w', encoding='utf-8') as f:
for i, (s, e, t) in enumerate(intro_synced_entries, 1):
f.write(f"{i}\n{seconds_to_srt(s)} --> {seconds_to_srt(e)}\n{t}\n\n")
print(f" Applied {extra:+.3f}s — intro now ends at {seconds_to_srt(intro_end_video)}")
print(f" Preview updated: {os.path.basename(intro_preview)}")
again = input(" Try another offset? [y/N]: ").strip().lower()
if again != 'y':
break
# ── Extract show audio from intro_end onwards ─────────────────────────────
print(f"\n Extracting show audio from {seconds_to_srt(intro_end_video)}...")
fd2, show_wav = tempfile.mkstemp(suffix='.wav')
os.close(fd2)
r = subprocess.run([
'ffmpeg', '-hide_banner', '-loglevel', 'error',
'-i', video, '-ss', str(intro_end_video),
'-vn', '-ac', '1', '-ar', '16000', '-y', show_wav
], timeout=600)
if r.returncode != 0:
print(" Failed to extract show audio — aborting.")
try: os.remove(show_wav)
except OSError: pass
return
# ── Pass 2: sync show SRT against the show audio clip ────────────────────
# ffsubsync finds the best alignment regardless of what offset the show SRT
# currently has; output timestamps are relative to the clip start (i.e.
# relative to intro_end_video).
show_ep = parse_srt_full(episode_srt)
print(f" Pass 2 of 2 — syncing {len(show_ep)} show entries against show audio...")
fd3, show_synced_tmp = tempfile.mkstemp(suffix='.srt')
os.close(fd3)
ok2, offset_B = sync_with_ffsubsync(show_wav, episode_srt, show_synced_tmp)
try: os.remove(show_wav)
except OSError: pass
if ok2 and offset_B is not None:
print(f" Show offset: {offset_B:+.3f}s (relative to intro end)")
show_synced_entries = parse_srt_full(show_synced_tmp)
else:
print(" Show sync failed — writing show entries unsynced as fallback.")
show_synced_entries = show_ep
try: os.remove(show_synced_tmp)
except OSError: pass
# ── Merge: intro (absolute) + show (relative → absolute) ─────────────────
base = os.path.splitext(episode_srt)[0]
out = f"{base}-splitsync.srt"
with open(out, 'w', encoding='utf-8') as f:
idx = 1
# Intro: timestamps already correct from pass 1
for s, e, t in intro_synced_entries:
f.write(f"{idx}\n{seconds_to_srt(s)} --> {seconds_to_srt(e)}\n{t}\n\n")
idx += 1
# Show: add intro_end_video to convert clip-relative → absolute video time
for s, e, t in show_synced_entries:
ws = s + intro_end_video
we = max(ws + 0.1, e + intro_end_video)
f.write(f"{idx}\n{seconds_to_srt(ws)} --> {seconds_to_srt(we)}\n{t}\n\n")
idx += 1
kb = os.path.getsize(out) / 1024
print(f"\n Done: {os.path.basename(out)} ({kb:.0f} KB, {idx-1} entries)")
print(f" Intro: {len(intro_synced_entries)} entries (offset {offset_A:+.3f}s)")
if ok2 and offset_B is not None:
print(f" Show: {len(show_synced_entries)} entries (offset {offset_B:+.3f}s from intro end)")
def sync_mode():
global WHISPER_MODEL, WHISPER_TASK, WHISPER_LANGUAGE
# --- Pick video ---
print("\n SYNC: Video files in current directory:")
vid_files = list_files(VIDEO_EXTS, "video")
if vid_files:
video = pick_file(vid_files, " Choose video by number or filename")
else:
video = input(" Enter path to video file (0 to cancel): ").strip()
if video == '0':
return
if not video or not os.path.isfile(video):
print(" No valid video selected.")
return
ffsubsync_ok = ensure_ffsubsync()
whisper_ok = WHISPER_AVAILABLE # don't install just to show the menu
# --- Sync method ---
print("\n Sync method:")
print(" f: ffsubsync only (fast, recommended) [default]")
print(" w: Whisper only (speech recognition)")
print(" b: Both - ffsubsync + Whisper cross-check")
print(" m: Manual offset (enter seconds yourself)")
print(" s: Split sync (intro + show have different offsets, uses intro.srt)")
print(" 0: Cancel")
while True:
ch = input(" Choose or Enter for default: ").strip().lower()
if ch == '':
ch = 'f'
break
if ch in ('f', 'w', 'b', 'm', 's', '0'):
break
print(" Enter f, w, b, m, s or 0.")
if ch == '0':
return
if ch == 's':
split_sync_intro_show(video)
return
if ch in ('w', 'b'):
whisper_ok = ensure_whisper()
if not whisper_ok:
print(" Whisper required for this method.")
return
print("\n Whisper model:")
for k, (name, desc) in WHISPER_MODELS.items():
marker = " <-- default" if name == WHISPER_MODEL else ""
print(f" {k}: {name:20s} {desc}{marker}")
choice = input(" Choose model [Enter for default]: ").strip()
if choice in WHISPER_MODELS:
WHISPER_MODEL = WHISPER_MODELS[choice][0]
print(f" Using: {WHISPER_MODEL}\n")
# --- Pick SRT ---
print("\n SRT files in current directory:")
srt_files = list_files('.srt', "SRT")
if srt_files:
src = pick_file(srt_files, " Choose SRT by number or filename")
else:
src = input(" Enter path to .srt file (0 to cancel): ").strip()
if src == '0':
return
if not src or not os.path.isfile(src):
print(" No valid SRT selected.")
return
# Detect SRT language from the actual subtitle text — offer English if non-English
also_english = False
lang_code, lang_name = _srt_detect_language(src)
if lang_code and lang_code != 'en':
print(f"\n Detected language: {lang_name}.")
if ensure_whisper():
whisper_ok = True
also_english = input(
f" Also generate an English SRT via Whisper translate after sync? [Y/n]: "
).strip().lower() != 'n'
# --- Sync ---
base = os.path.splitext(src)[0]
out = f"{base}-synced.srt"
print(f"\n Syncing -> {os.path.basename(out)}")
synced_ok = False
if ch == 'f':
ok, offset = sync_with_ffsubsync(video, src, out)
if ok:
if offset is not None:
print(f" Offset applied: {offset:+.3f} s")
print(f" Done: {os.path.basename(out)}")
synced_ok = True
else:
print(" ffsubsync failed.")
while True:
resp = input(" Enter offset manually (seconds, e.g. -3.5) or Enter to skip: ").strip()
if resp == '':
break
offset = parse_offset(resp)
if offset is None:
print(" Invalid.")
else:
shift_srt(src, out, offset)
print(f" Written -> {os.path.basename(out)}")
synced_ok = True
break
elif ch == 'w':
offset, n_matches, spread, err = compute_offset_whisper(src, video, WHISPER_MODEL)
if not err:
quality = "good" if spread < 2.0 else "moderate" if spread < 5.0 else "low"
print(f" Whisper offset: {offset:+.3f} s ({n_matches} matches, spread {spread:.1f}s, {quality})")
shift_srt(src, out, offset)
print(f" Done: {os.path.basename(out)}")
synced_ok = True
else:
print(f" Whisper failed: {err}")
elif ch == 'm':
print(" Subtitle text appears BEFORE you hear it → use a POSITIVE number (+0.52)")
print(" You hear the sound BEFORE the text appears → use a NEGATIVE number (-0.52)")
print(" Enter 0 or blank to cancel.")
last_offset = 0.0
while True:
hint = f" Offset seconds [last: {last_offset:+.3f}]: "
resp = input(hint).strip()
if resp in ('0', ''):
break
offset = parse_offset(resp)
if offset is None:
print(" Invalid — enter a number like 1.5 or -0.52.")
continue
last_offset = offset
shift_srt(src, out, offset)
print(f" Written -> {os.path.basename(out)}")
synced_ok = True
again = input(" Try another offset? [y/N]: ").strip().lower()
if again != 'y':
break
# re-apply to original each time so offsets don't stack
print(" (applying to original each time — offsets do not stack)")
else: # b
if sync_single(video, src, out, ffsubsync_ok, whisper_ok, interactive=True):
print(f" Done: {os.path.basename(out)}")
synced_ok = True
else:
while True:
resp = input("\n All methods failed. Enter offset manually or Enter to skip: ").strip()
if resp == '':
break
offset = parse_offset(resp)
if offset is None:
print(" Invalid.")
else:
shift_srt(src, out, offset)
print(f" Written -> {os.path.basename(out)}")
synced_ok = True
break
# --- Also generate English SRT? ---
if also_english and whisper_ok:
print("\n Generating English SRT via Whisper translate...")
WHISPER_TASK = 'translate'
WHISPER_LANGUAGE = None
final = generate_and_sync(video, WHISPER_MODEL, ffsubsync_ok=ffsubsync_ok)
if final:
print(f" English SRT: {os.path.basename(final)}")
if synced_ok:
offer_rename(video)
# ---------- Main -------------------------------------------------------------
def main():
global WHISPER_MODEL, WHISPER_LANGUAGE, WHISPER_TASK
if '--translate' in sys.argv:
WHISPER_TASK = 'translate'
WHISPER_LANGUAGE = None # auto-detect source; --lang overrides below
print("Translate mode: Whisper will output English regardless of source language.")
if '--extract-all' in sys.argv:
idx = sys.argv.index('--extract-all')
if idx + 1 < len(sys.argv):
_extract_all_noninteractive(sys.argv[idx + 1])
else:
print("--extract-all requires a file path.", file=sys.stderr)
sys.exit(1)
if '--lang' in sys.argv:
idx = sys.argv.index('--lang')
if idx + 1 < len(sys.argv):
WHISPER_LANGUAGE = sys.argv[idx + 1]
print(f"Language override: {WHISPER_LANGUAGE}")
else:
print("--lang requires a language code (e.g. --lang fr). Using default.")
elif '--lang-auto' in sys.argv:
WHISPER_LANGUAGE = None
print("Language: auto-detect")
while True:
print("\nWhat would you like to do?")
print(" 1: SYNC - sync an existing SRT to the video")
gen_label = "translate foreign audio → English SRT" if WHISPER_TASK == 'translate' \
else "create a new SRT by transcribing with Whisper"
print(f" 2: GENERATE - {gen_label}")
print(" 3: BATCH - sync all video+SRT pairs in this directory")
print(" 4: RENAME - rename video + SRT to Plex format")
print(" 5: EXTRACT - extract embedded subtitles / CC to SRT")
print(" 6: REMUX - convert MP4 → MKV (stream copy, no re-encode)")
print(" 7: EMBED - soft-mux subtitle file into video (mkvmerge)")
print(" 8: BURNSUBS - OCR burnt-in subs → SRT and/or erase from video")
print(" 0: Exit")
while True:
mode = input("Choose: ").strip()
if mode in ('0', '1', '2', '3', '4', '5', '6', '7', '8'):
break
print("Please enter 0-8.")
if mode == '0':
print("Goodbye.")
break
# ---- Mode 4: Rename (has its own sub-menu loop) ---------------------
if mode == '4':
ocr_ok = ensure_easyocr()
rename_menu(ocr_ok)
continue
# ---- Mode 5: Extract subtitles --------------------------------------
if mode == '5':
extract_subs_mode()
continue
# ---- Mode 6: Remux MP4 → MKV ----------------------------------------
if mode == '6':
remux_mp4_to_mkv()
continue
# ---- Mode 7: Embed subtitle into video ------------------------------
if mode == '7':
embed_subs_mode()
continue
# ---- Mode 8: Burnt-in subtitle OCR / removal ------------------------
if mode == '8':
burnt_in_subs_mode()
continue
# ---- Mode 1: Sync / transcribe / translate --------------------------
if mode == '1':
sync_mode()
continue
# ---- Mode 2: Generate SRT -------------------------------------------
whisper_ok = ensure_whisper()
ffsubsync_ok = ensure_ffsubsync()
if not whisper_ok:
print("Whisper is required to generate an SRT.")
continue
if whisper_ok:
print("\nWhisper model (larger = more accurate, more RAM, slower first load):")
for k, (name, desc) in WHISPER_MODELS.items():
marker = " <-- default" if name == WHISPER_MODEL else ""
print(f" {k}: {name:20s} {desc}{marker}")
choice = input("Choose model [Enter for default]: ").strip()
if choice in WHISPER_MODELS:
WHISPER_MODEL = WHISPER_MODELS[choice][0]
print(f" Using: {WHISPER_MODEL}\n")
# ---- Mode 3: Batch sync ---------------------------------------------
if mode == '3':
batch_sync(ffsubsync_ok, whisper_ok)
continue
print("\nVideo files in current directory:")
vid_files = list_files(VIDEO_EXTS, "video")
if vid_files:
video = pick_file(vid_files, "Choose video by number or filename")
else:
video = input("Enter path to video file (0 to cancel): ").strip()
if video == '0':
continue
if not video or not os.path.isfile(video):
print("No valid video selected.")
continue
final = generate_and_sync(video, WHISPER_MODEL, ffsubsync_ok=ffsubsync_ok)
if final:
print(f"\nDone - final SRT: {final}")
offer_rename(video)
if __name__ == '__main__':
main()