197 lines
6.1 KiB
Python
Executable File
197 lines
6.1 KiB
Python
Executable File
#!/usr/bin/env -S uv run --script
|
|
# /// script
|
|
# dependencies = []
|
|
# ///
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Embed subtitle files into videos while preserving existing subtitle tracks. Supports processing multiple videos at once.",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"files",
|
|
type=Path,
|
|
nargs="+",
|
|
help="Video files and subtitle files (in any order). Subtitle files must follow pattern: {video_stem}.{lang}.srt",
|
|
)
|
|
parser.add_argument(
|
|
"--output-dir",
|
|
"-d",
|
|
type=Path,
|
|
help="Output directory for processed videos (default: same directory as input video)",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def extract_language_from_subtitle(video_stem: str, subtitle_path: Path) -> str | None:
|
|
pattern = re.compile(rf"^{re.escape(video_stem)}\.([a-z]{{2}})\.srt$", re.IGNORECASE)
|
|
match = pattern.match(subtitle_path.name)
|
|
|
|
if not match:
|
|
return None
|
|
|
|
return match.group(1).lower()
|
|
|
|
|
|
def match_videos_and_subtitles(paths: list[Path]) -> dict[Path, dict[str, Path]]:
|
|
video_extensions = [".mp4", ".mkv"]
|
|
video_files = [f for f in paths if f.suffix.lower() in video_extensions]
|
|
subtitle_files = [f for f in paths if f.suffix.lower() == ".srt"]
|
|
|
|
if len(video_files) == 0:
|
|
raise ValueError("No video files provided. Videos must have .mp4 or .mkv extension.")
|
|
if len(subtitle_files) == 0:
|
|
raise ValueError("No subtitle files provided. Subtitle files must have .srt extension.")
|
|
|
|
other_files = [f for f in paths if f not in video_files and f not in subtitle_files]
|
|
if other_files:
|
|
raise ValueError(f"Unknown file types: {[str(f) for f in other_files]}")
|
|
|
|
video_stems = {video.stem: video for video in video_files}
|
|
grouped = {video: {} for video in video_files}
|
|
|
|
for subtitle_path in subtitle_files:
|
|
matched = False
|
|
for video_stem, video_path in video_stems.items():
|
|
lang = extract_language_from_subtitle(video_stem=video_stem, subtitle_path=subtitle_path)
|
|
if lang is not None:
|
|
if lang in grouped[video_path]:
|
|
raise ValueError(
|
|
f"Duplicate language code '{lang}' for video '{video_path.name}': {subtitle_path.name} and {grouped[video_path][lang].name}"
|
|
)
|
|
grouped[video_path][lang] = subtitle_path
|
|
matched = True
|
|
break
|
|
|
|
if not matched:
|
|
logging.warning(f"Skipping subtitle file '{subtitle_path.name}': does not match any video file pattern")
|
|
|
|
return grouped
|
|
|
|
|
|
def probe_existing_subtitles(video_path: Path) -> int:
|
|
cmd = [
|
|
"ffprobe",
|
|
"-v",
|
|
"error",
|
|
"-select_streams",
|
|
"s",
|
|
"-show_entries",
|
|
"stream=index",
|
|
"-of",
|
|
"json",
|
|
str(video_path),
|
|
]
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
data = json.loads(result.stdout)
|
|
streams = data.get("streams", [])
|
|
|
|
return len(streams)
|
|
|
|
|
|
def detect_subtitle_codec(output_path: Path) -> str:
|
|
ext = output_path.suffix.lower()
|
|
if ext == ".mp4":
|
|
return "mov_text"
|
|
elif ext == ".mkv":
|
|
return "srt"
|
|
else:
|
|
raise ValueError(f"Unsupported output container format: {ext}. Supported formats: .mp4, .mkv")
|
|
|
|
|
|
def embed_subtitles(
|
|
video_path: Path,
|
|
subtitles: dict[str, Path],
|
|
output_path: Path,
|
|
) -> Path:
|
|
existing_subtitle_count = probe_existing_subtitles(video_path=video_path)
|
|
subtitle_codec = detect_subtitle_codec(output_path=output_path)
|
|
|
|
logging.info(f"Video: {video_path.name}")
|
|
logging.info(f"Existing subtitle tracks: {existing_subtitle_count}")
|
|
logging.info(f"Adding {len(subtitles)} new subtitle track(s): {list(subtitles.keys())}")
|
|
logging.info(f"Output: {output_path.name}")
|
|
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-y",
|
|
"-i",
|
|
str(video_path),
|
|
]
|
|
|
|
for subtitle_path in subtitles.values():
|
|
cmd.extend(["-i", str(subtitle_path)])
|
|
|
|
cmd.extend(["-c:v", "copy", "-c:a", "copy"])
|
|
|
|
cmd.extend(["-map", "0"])
|
|
|
|
for i in range(len(subtitles)):
|
|
cmd.extend(["-map", f"{i + 1}:s"])
|
|
|
|
for i, lang in enumerate(subtitles.keys()):
|
|
subtitle_index = existing_subtitle_count + i
|
|
cmd.extend([f"-c:s:{subtitle_index}", subtitle_codec])
|
|
cmd.extend([f"-metadata:s:s:{subtitle_index}", f"language={lang}"])
|
|
|
|
cmd.append(str(output_path))
|
|
|
|
logging.info("Executing ffmpeg command...")
|
|
subprocess.run(cmd, check=True)
|
|
|
|
return output_path
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
|
|
for file_path in args.files:
|
|
if not file_path.exists():
|
|
raise FileNotFoundError(f"File not found: {file_path}")
|
|
|
|
if args.output_dir and not args.output_dir.exists():
|
|
raise FileNotFoundError(f"Output directory not found: {args.output_dir}")
|
|
|
|
grouped_subtitles = match_videos_and_subtitles(paths=args.files)
|
|
|
|
videos_processed = 0
|
|
videos_skipped = 0
|
|
|
|
for video_path, subtitles in grouped_subtitles.items():
|
|
if len(subtitles) == 0:
|
|
logging.info(f"Skipping '{video_path.name}': no matching subtitle files found")
|
|
videos_skipped += 1
|
|
continue
|
|
|
|
if args.output_dir:
|
|
output_path = args.output_dir / f"{video_path.stem}.subbed{video_path.suffix}"
|
|
else:
|
|
output_path = video_path.with_name(f"{video_path.stem}.subbed{video_path.suffix}")
|
|
|
|
logging.info(f"\n{'=' * 60}")
|
|
result_path = embed_subtitles(
|
|
video_path=video_path,
|
|
subtitles=subtitles,
|
|
output_path=output_path,
|
|
)
|
|
logging.info(f"✓ Successfully embedded subtitles: {result_path}")
|
|
videos_processed += 1
|
|
|
|
logging.info(f"\n{'=' * 60}")
|
|
logging.info(f"Summary: {videos_processed} video(s) processed, {videos_skipped} video(s) skipped")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|