Last active
January 31, 2026 18:00
-
-
Save AlexanderMakarov/be78d387d2b9bb62d12c0be3452790dc to your computer and use it in GitHub Desktop.
Interactive Python 3 script to remove audio and subtitle tracks from MKV video files without touching video track(s). Requires mkvmerge (mkvtoolnix) or (slower) ffmpeg and ffprobe CLI installed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| import sys | |
| import json | |
| import subprocess | |
| import shutil | |
| import os | |
| import re | |
| import threading | |
| import time | |
| def check_dependencies(): | |
| """Check if ffmpeg and ffprobe are installed. mkvmerge is optional (faster for MKV).""" | |
| if not shutil.which('ffmpeg'): | |
| print("Error: ffmpeg is not installed") | |
| print("Install it with: sudo apt-get install ffmpeg") | |
| sys.exit(1) | |
| if not shutil.which('ffprobe'): | |
| print("Error: ffprobe is not installed") | |
| print("Install it with: sudo apt-get install ffmpeg") | |
| sys.exit(1) | |
| return shutil.which('mkvmerge') is not None | |
| def get_stream_info(input_file): | |
| """Get stream information from the file using ffprobe.""" | |
| try: | |
| result = subprocess.run( | |
| ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', input_file], | |
| capture_output=True, | |
| text=True, | |
| check=True | |
| ) | |
| return json.loads(result.stdout) | |
| except subprocess.CalledProcessError as e: | |
| print(f"Error: Failed to analyze file: {e}") | |
| sys.exit(1) | |
| except json.JSONDecodeError as e: | |
| print(f"Error: Failed to parse stream information: {e}") | |
| sys.exit(1) | |
| def display_tracks(streams, track_type): | |
| """Display tracks of a specific type (audio or subtitle).""" | |
| tracks = [] | |
| for stream in streams: | |
| if stream.get('codec_type') == track_type: | |
| idx = stream.get('index') | |
| codec_name = stream.get('codec_name', 'unknown') | |
| codec_long = stream.get('codec_long_name', '') | |
| language = stream.get('tags', {}).get('language', 'unknown') | |
| title = stream.get('tags', {}).get('title', '') | |
| tracks.append({ | |
| 'index': idx, | |
| 'codec': codec_name, | |
| 'codec_long': codec_long, | |
| 'language': language, | |
| 'title': title | |
| }) | |
| return tracks | |
| def get_user_selection(tracks, track_type): | |
| """Get user selection for which tracks to keep.""" | |
| if not tracks: | |
| print(f"\nNo {track_type} tracks found.") | |
| return [] | |
| print(f"\n{track_type.capitalize()} tracks:") | |
| for i, track in enumerate(tracks): | |
| info_parts = [f"Stream {track['index']}", track['codec']] | |
| if track['language'] != 'unknown': | |
| info_parts.append(f"lang: {track['language']}") | |
| if track['title']: | |
| info_parts.append(f"title: {track['title']}") | |
| print(f" [{i}] {' | '.join(info_parts)}") | |
| print(f"\nEnter {track_type} track numbers to keep (space-separated, or 'all' for all, or 'none' for none):") | |
| user_input = input("> ").strip().lower() | |
| if user_input == 'all': | |
| return [track['index'] for track in tracks] | |
| elif user_input == 'none' or user_input == '': | |
| return [] | |
| try: | |
| indices = [int(x.strip()) for x in user_input.split()] | |
| selected = [] | |
| for idx in indices: | |
| if 0 <= idx < len(tracks): | |
| selected.append(tracks[idx]['index']) | |
| else: | |
| print(f"Warning: Track number {idx} is out of range, skipping.") | |
| return selected | |
| except ValueError: | |
| print(f"Error: Invalid input. Please enter space-separated numbers.") | |
| return get_user_selection(tracks, track_type) | |
| def _format_eta(seconds: float) -> str: | |
| """Format seconds as M:SS or H:MM:SS for ETA display.""" | |
| if seconds < 0 or seconds != seconds: # NaN | |
| return "?" | |
| s = int(round(seconds)) | |
| if s < 60: | |
| return f"{s}s" | |
| m, s = divmod(s, 60) | |
| if m < 60: | |
| return f"{m}:{s:02d}s" | |
| h, m = divmod(m, 60) | |
| return f"{h}:{m:02d}:{s:02d}s" | |
| def run_mkvmerge(input_file, output_file, audio_streams, subtitle_streams): | |
| """Run mkvmerge to keep only selected tracks. Much faster than ffmpeg for MKV remux.""" | |
| # mkvmerge track IDs match ffprobe stream index for MKV (both 0-based, same order) | |
| # Run without -q so mkvmerge prints progress; we stream it and optionally show ETA | |
| cmd = ['mkvmerge', '-o', output_file] | |
| if audio_streams: | |
| cmd.extend(['--audio-tracks', ','.join(str(i) for i in audio_streams)]) | |
| else: | |
| cmd.append('-A') # --no-audio | |
| if subtitle_streams: | |
| cmd.extend(['--subtitle-tracks', ','.join(str(i) for i in subtitle_streams)]) | |
| else: | |
| cmd.append('-S') # --no-subtitles | |
| cmd.append(input_file) | |
| print(f"\nRunning mkvmerge (fast MKV remux):") | |
| print(f" {' '.join(cmd)}") | |
| print() | |
| progress_pct_re = re.compile(r'(\d+)\s*%') | |
| start_time = time.monotonic() | |
| last_pct = -1 | |
| last_eta_print = 0.0 | |
| try: | |
| process = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.STDOUT, | |
| universal_newlines=True, | |
| bufsize=1, | |
| ) | |
| out = process.stdout | |
| if out: | |
| for line in iter(out.readline, ''): | |
| line = line.rstrip('\n\r') | |
| if not line: | |
| continue | |
| # Forward mkvmerge's progress line (may use \r, so we print with \r for in-place update) | |
| is_progress = '%' in line | |
| if is_progress: | |
| sys.stdout.write('\r' + line) | |
| sys.stdout.flush() | |
| # Parse percentage and show ETA | |
| m = progress_pct_re.search(line) | |
| if m: | |
| pct = int(m.group(1)) | |
| if 0 < pct <= 100 and pct != last_pct: | |
| last_pct = pct | |
| elapsed = time.monotonic() - start_time | |
| now = time.monotonic() | |
| if elapsed > 2 and pct >= 2 and (now - last_eta_print) >= 1.0: | |
| last_eta_print = now | |
| remaining = elapsed * (100 - pct) / pct | |
| eta_str = _format_eta(remaining) | |
| sys.stdout.write(f" ETA {eta_str} ") | |
| sys.stdout.flush() | |
| else: | |
| if last_pct >= 0: | |
| sys.stdout.write('\n') | |
| sys.stdout.flush() | |
| last_pct = -1 | |
| print(line) | |
| if last_pct >= 0: | |
| sys.stdout.write('\n') | |
| sys.stdout.flush() | |
| process.wait() | |
| if process.returncode != 0: | |
| print(f"\nError: mkvmerge failed with return code {process.returncode}") | |
| sys.exit(1) | |
| except KeyboardInterrupt: | |
| print("\n\nProcess interrupted by user.") | |
| sys.exit(1) | |
| except Exception as e: | |
| print(f"\nError running mkvmerge: {e}") | |
| sys.exit(1) | |
| def run_ffmpeg(input_file, output_file, audio_streams, subtitle_streams): | |
| """Run ffmpeg with the selected streams and show real-time output (fallback).""" | |
| cmd = ['ffmpeg', '-i', input_file] | |
| cmd.extend(['-map', '0:v']) | |
| for audio_idx in audio_streams: | |
| cmd.extend(['-map', f'0:{audio_idx}']) | |
| for sub_idx in subtitle_streams: | |
| cmd.extend(['-map', f'0:{sub_idx}']) | |
| cmd.extend(['-c', 'copy', '-y', output_file]) | |
| print(f"\nRunning ffmpeg command:") | |
| print(f" {' '.join(cmd)}") | |
| print(f"\nProcessing... (this may take a while)\n") | |
| try: | |
| process = subprocess.Popen( | |
| cmd, | |
| stdout=subprocess.PIPE, | |
| stderr=subprocess.PIPE, | |
| universal_newlines=True, | |
| bufsize=1 | |
| ) | |
| stderr_fd = process.stderr | |
| def read_stderr(): | |
| if stderr_fd: | |
| for line in iter(stderr_fd.readline, ''): | |
| if line: | |
| print(line, end='', flush=True) | |
| stderr_thread = threading.Thread(target=read_stderr, daemon=True) | |
| stderr_thread.start() | |
| process.wait() | |
| stderr_thread.join(timeout=1) | |
| if process.returncode != 0: | |
| print(f"\nError: ffmpeg failed with return code {process.returncode}") | |
| sys.exit(1) | |
| except KeyboardInterrupt: | |
| print("\n\nProcess interrupted by user.") | |
| if 'process' in locals() and process.poll() is None: | |
| process.terminate() | |
| sys.exit(1) | |
| except Exception as e: | |
| print(f"\nError running ffmpeg: {e}") | |
| sys.exit(1) | |
| def format_size(size_bytes): | |
| """Format file size in human-readable format.""" | |
| for unit in ['B', 'KB', 'MB', 'GB', 'TB']: | |
| if size_bytes < 1024.0: | |
| return f"{size_bytes:.2f} {unit}" | |
| size_bytes /= 1024.0 | |
| return f"{size_bytes:.2f} PB" | |
| def print_usage(): | |
| """Print usage and help to stdout.""" | |
| prog = os.path.basename(sys.argv[0]) | |
| print(f"Usage: {prog} <input.mkv> [output.mkv]") | |
| print() | |
| print(" input.mkv Input MKV file (required)") | |
| print(" output.mkv Output file (optional). If omitted, defaults to <input>_processed.mkv") | |
| print() | |
| print("Options:") | |
| print(" -h, --help Show this help and exit") | |
| print() | |
| print("Interactive track selection for audio and subtitles; video is always kept.") | |
| def main(): | |
| if len(sys.argv) < 2: | |
| print_usage() | |
| sys.exit(1) | |
| if sys.argv[1] in ('--help', '-h'): | |
| print_usage() | |
| sys.exit(0) | |
| input_file = sys.argv[1] | |
| output_file = sys.argv[2] if len(sys.argv) > 2 else input_file.replace('.mkv', '_processed.mkv') | |
| if not os.path.isfile(input_file): | |
| print(f"Error: Input file '{input_file}' not found") | |
| sys.exit(1) | |
| use_mkvmerge = check_dependencies() | |
| if use_mkvmerge: | |
| print("Using mkvmerge for fast MKV remux (video + selected audio/subs only).") | |
| else: | |
| print("mkvmerge not found; using ffmpeg (install mkvtoolnix for faster processing).") | |
| print(f"Analyzing file: {input_file}") | |
| stream_data = get_stream_info(input_file) | |
| streams = stream_data.get('streams', []) | |
| audio_tracks = display_tracks(streams, 'audio') | |
| subtitle_tracks = display_tracks(streams, 'subtitle') | |
| selected_audio = get_user_selection(audio_tracks, 'audio') | |
| selected_subtitles = get_user_selection(subtitle_tracks, 'subtitle') | |
| if not selected_audio and not selected_subtitles: | |
| print("\nWarning: No audio or subtitle tracks selected. Only video will be copied.") | |
| response = input("Continue? (y/n): ").strip().lower() | |
| if response != 'y': | |
| print("Aborted.") | |
| sys.exit(0) | |
| print(f"\nSelected audio tracks: {selected_audio if selected_audio else 'none'}") | |
| print(f"Selected subtitle tracks: {selected_subtitles if selected_subtitles else 'none'}") | |
| print(f"Output file: {output_file}") | |
| if use_mkvmerge: | |
| run_mkvmerge(input_file, output_file, selected_audio, selected_subtitles) | |
| else: | |
| run_ffmpeg(input_file, output_file, selected_audio, selected_subtitles) | |
| if os.path.isfile(output_file): | |
| input_size = os.path.getsize(input_file) | |
| output_size = os.path.getsize(output_file) | |
| print(f"\n{'='*60}") | |
| print("Success! File processed.") | |
| print(f"Original file size: {format_size(input_size)}") | |
| print(f"New file size: {format_size(output_size)}") | |
| print(f"{'='*60}") | |
| if __name__ == '__main__': | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment