Add a Python utility to recursively repackage non-MKV video files into Matroska containers using FFmpeg, preserving all original streams and metadata without re-encoding.
140 lines
4.0 KiB
Python
Executable File
140 lines
4.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""A utility script to repackage video files into MKV containers using FFmpeg.
|
|
|
|
This script recursively scans a specified directory for non-MKV video files
|
|
and uses FFmpeg to repackage them into '.mkv' containers. It explicitly maps
|
|
all streams (video, audio, subtitles, data) and preserves all metadata without
|
|
re-encoding. It includes options to automatically delete the source files upon
|
|
successful conversion.
|
|
"""
|
|
|
|
import argparse
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Sequence
|
|
|
|
# Define the targeted non-MKV extensions (case-insensitive handling below)
|
|
TARGET_EXTENSIONS = {".mp4", ".m4v", ".flv", ".avi", ".mov", ".wmv"}
|
|
|
|
|
|
def parse_args(args: Sequence[str] | None = None) -> argparse.Namespace:
|
|
"""Parse command-line arguments for the repackaging utility.
|
|
|
|
Args:
|
|
args: Optional sequence of strings to parse. If None, sys.argv is used.
|
|
|
|
Returns:
|
|
An argparse.Namespace object containing the parsed arguments.
|
|
"""
|
|
parser = argparse.ArgumentParser(
|
|
description="Recursively repackage non-MKV video files into MKV containers, preserving all streams and metadata."
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-i",
|
|
"--input",
|
|
type=Path,
|
|
default=Path.cwd(),
|
|
help="Path to the root directory to scan for video files (default: current working directory).",
|
|
)
|
|
|
|
parser.add_argument(
|
|
"-d",
|
|
"--delete",
|
|
action="store_true",
|
|
default=False,
|
|
help="Delete the original video files upon successful conversion.",
|
|
)
|
|
|
|
# Enable argcomplete support if the module is available
|
|
try:
|
|
import argcomplete
|
|
|
|
argcomplete.autocomplete(parser)
|
|
except ImportError:
|
|
pass
|
|
|
|
return parser.parse_args(args)
|
|
|
|
|
|
def repackage_to_mkv(input_dir: Path, delete_source: bool) -> None:
|
|
"""Recursively scan the input directory and repackage non-MKV videos to MKV.
|
|
|
|
Args:
|
|
input_dir: The root Path directory to scan.
|
|
delete_source: If True, deletes the original video file after a
|
|
successful FFmpeg operation.
|
|
"""
|
|
if not input_dir.is_dir():
|
|
print(f"Error: The directory '{input_dir}' does not exist.")
|
|
return
|
|
|
|
print(f"Scanning '{input_dir}' recursively for video files...")
|
|
|
|
video_files = [
|
|
p
|
|
for p in input_dir.rglob("*")
|
|
if p.is_file() and p.suffix.lower() in TARGET_EXTENSIONS
|
|
]
|
|
|
|
if not video_files:
|
|
print("No matching video files found.")
|
|
return
|
|
|
|
print(f"Found {len(video_files)} files to process.\n")
|
|
|
|
for file_path in video_files:
|
|
output_path = file_path.with_suffix(".mkv")
|
|
|
|
if output_path.exists():
|
|
print(
|
|
f"Warning: Destination {output_path.name} already exists. Skipping to avoid accidental overwrite."
|
|
)
|
|
print("-" * 40)
|
|
continue
|
|
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-i",
|
|
str(file_path),
|
|
"-map",
|
|
"0",
|
|
"-c",
|
|
"copy",
|
|
"-map_metadata",
|
|
"0",
|
|
str(output_path),
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
]
|
|
|
|
# Display the relative path from the input directory so it's easier to track progress
|
|
relative_display = file_path.relative_to(input_dir)
|
|
print(f"Processing: {relative_display} -> {output_path.name}...")
|
|
|
|
try:
|
|
subprocess.run(cmd, check=True)
|
|
print(f"Successfully converted: {file_path.name}")
|
|
|
|
if delete_source:
|
|
file_path.unlink()
|
|
print(f"Deleted original file: {file_path.name}")
|
|
|
|
except subprocess.CalledProcessError:
|
|
print(f"Failed to convert: {file_path.name}")
|
|
except OSError as e:
|
|
print(f"System error processing {file_path.name}: {e}")
|
|
|
|
print("-" * 40)
|
|
|
|
|
|
def main() -> None:
|
|
"""Main execution entry point."""
|
|
args = parse_args()
|
|
repackage_to_mkv(input_dir=args.input, delete_source=args.delete)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|