feat: add track listing and language-based targeting to video_manage_audio.py

- Added a 'list' command using ffprobe to display all audio streams.
- Updated track selection to support both relative index and language codes.
This commit is contained in:
Fabrice Quenneville 2026-05-17 05:14:44 -04:00
parent 98b757a9b1
commit 642f6d90db

View File

@ -4,6 +4,7 @@
import argparse import argparse
import argcomplete import argcomplete
import colorama import colorama
import json
import os import os
import subprocess import subprocess
@ -17,39 +18,153 @@ cgreen = colorama.Fore.GREEN
cred = colorama.Fore.RED cred = colorama.Fore.RED
def process_audio(file_path, track, command): def get_audio_metadata(file_path):
""" """
Modify audio tracks of a video file based on the given command. Uses ffprobe to extract audio stream information.
- 'remove': Remove the specified audio track while keeping everything else. Returns a list of dicts with track details.
- 'keep': Keep only the specified audio track and remove all others.
The function preserves video, subtitles, and metadata.
""" """
abs_file_path = os.path.abspath(file_path)
ffprobe_command = [
"ffprobe",
"-v",
"error",
"-select_streams",
"a",
"-show_entries",
"stream=index,codec_name:stream_tags=language,title",
"-of",
"json",
abs_file_path,
]
try:
result = subprocess.run(
ffprobe_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if result.returncode != 0:
return None
metadata = json.loads(result.stdout)
return metadata.get("streams", [])
except Exception:
return None
def list_audio_tracks(file_path):
"""Displays all audio tracks in a video file."""
print(f"{ccyan}Analyzing tracks for: {file_path}{creset}")
streams = get_audio_metadata(file_path)
if streams is None:
print(f"{cred} Failed to read file metadata.{creset}\n")
return
if not streams:
print(f"{cyellow} No audio tracks found in this file.{creset}\n")
return
for audio_idx, stream in enumerate(streams):
abs_idx = stream.get("index")
codec = stream.get("codec_name", "unknown")
tags = stream.get("tags", {})
lang = tags.get("language", "und")
title = tags.get("title", "No Title")
print(
f" {cgreen}[Audio Track {audio_idx}]{creset} "
f"Absolute Stream Index: {abs_idx} | "
f"Codec: {codec} | Lang: {lang} | Title: {title}"
)
print()
def resolve_track_target(streams, target):
"""
Resolves a track target (either an integer index string like '1'
or a language code string like 'eng') to a relative audio track index.
Returns None if no match is found.
"""
# Case 1: Target is an explicit number (e.g., "0", "1")
if target.isdigit():
idx = int(target)
if 0 <= idx < len(streams):
return idx
return None
# Case 2: Target is a language code (e.g., "eng", "rus")
for audio_idx, stream in enumerate(streams):
lang = stream.get("tags", {}).get("language", "").lower()
if lang == target.lower():
return audio_idx
return None
def process_audio(file_path, track_target, command):
"""Modify or list audio tracks of a video file."""
if command == "list":
list_audio_tracks(file_path)
return
streams = get_audio_metadata(file_path)
if not streams:
print(f"{cred}Skipping {file_path}: Could not parse audio streams.{creset}\n")
return
# Dynamic target resolution (converts 'eng' or '1' to the proper track index)
resolved_track = resolve_track_target(streams, track_target)
if resolved_track is None:
print(
f"{cyellow}Skipping {file_path}: Target audio track/language '{track_target}' not found.{creset}\n"
)
return
print(f"{cgreen}Processing file: {file_path}{creset}") print(f"{cgreen}Processing file: {file_path}{creset}")
print(
f"-> Target resolved to Audio Track {resolved_track} based on '{track_target}'"
)
output_file = f"{os.path.splitext(file_path)[0]}_{command}_audio{os.path.splitext(file_path)[1]}" output_file = f"{os.path.splitext(file_path)[0]}_{command}_audio{os.path.splitext(file_path)[1]}"
# Construct ffmpeg command based on user choice
if command == "remove": if command == "remove":
# Remove only the specified audio track while keeping all other streams # Remove only the specified audio track while keeping all other streams
ffmpeg_command = [ ffmpeg_command = [
"ffmpeg", "-i", file_path, "-map", "0", "-map", f"-0:a:{track}", "ffmpeg",
"-c", "copy", output_file "-i",
file_path,
"-map",
"0",
"-map",
f"-0:a:{resolved_track}",
"-c",
"copy",
output_file,
] ]
elif command == "keep": elif command == "keep":
# Keep only the specified audio track while preserving video, subtitles, and metadata # Keep only the specified audio track while preserving video, subtitles, and metadata
ffmpeg_command = [ ffmpeg_command = [
"ffmpeg", "-i", file_path, "-map", "0:v", "-map", f"0:a:{track}", "ffmpeg",
"-map", "0:s?", "-map", "0:t?", "-c", "copy", output_file "-i",
file_path,
"-map",
"0:v",
"-map",
f"0:a:{resolved_track}",
"-map",
"0:s?",
"-map",
"0:t?",
"-c",
"copy",
output_file,
] ]
else: else:
print(f"{cred}Invalid command: {command}{creset}")
return return
try: try:
# Execute the ffmpeg command and capture output result = subprocess.run(
result = subprocess.run(ffmpeg_command, ffmpeg_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
stdout=subprocess.PIPE, )
stderr=subprocess.PIPE,
text=True)
if result.returncode == 0: if result.returncode == 0:
print( print(
@ -61,20 +176,17 @@ def process_audio(file_path, track, command):
) )
print(f"{cred}Error output:\n{result.stderr}{creset}\n") print(f"{cred}Error output:\n{result.stderr}{creset}\n")
except subprocess.CalledProcessError as e: except Exception as e:
print(f"{cred}Error processing audio: {e}{creset}\n") print(f"{cred}Error processing audio: {e}{creset}\n")
def process_directory(dir_path, track, command): def process_directory(dir_path, track_target, command):
""" """Recursively processes all video files in the specified directory."""
Recursively processes all video files in the specified directory.
Applies the chosen audio modification (remove or keep) to each file.
"""
for root, _, files in os.walk(dir_path): for root, _, files in os.walk(dir_path):
for file in files: for file in files:
if file.endswith((".mp4", ".mkv", ".avi", ".mov")): if file.endswith((".mp4", ".mkv", ".avi", ".mov")):
file_path = os.path.join(root, file) file_path = os.path.join(root, file)
process_audio(file_path, track, command) process_audio(file_path, track_target, command)
def main(): def main():
@ -82,37 +194,31 @@ def main():
Main function to parse command-line arguments and initiate the audio processing. Main function to parse command-line arguments and initiate the audio processing.
""" """
# Create argument parser
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Manage audio tracks in video files.") description="Manage or list audio tracks in video files dynamically."
)
# Define command line arguments parser.add_argument(
# Add a positional argument for the command "command", choices=["remove", "keep", "list"], help="Command to run"
parser.add_argument('command', )
choices=['remove', 'keep'],
help='Command to run')
# Add other arguments with both short and long options, including defaults parser.add_argument(
parser.add_argument("-t", "-t",
"--track", "--track",
type=int,
default=0,
help="Audio track index (default is 0).")
parser.add_argument("-f",
"--file",
type=str, type=str,
help="Path to a specific video file.") default="0",
help="Audio track index (e.g., 0, 1) OR language ISO code (e.g., eng, rus). Default is '0'.",
)
parser.add_argument("-f", "--file", type=str, help="Path to a specific video file.")
parser.add_argument( parser.add_argument(
"-d", "-d",
"--dir", "--dir",
type=str, type=str,
default=os.getcwd(), default=os.getcwd(),
help="Directory to process (default is current directory).") help="Directory to process (default is current directory).",
)
# Enable autocomplete for command-line arguments
argcomplete.autocomplete(parser) argcomplete.autocomplete(parser)
# Parse command line arguments
args = parser.parse_args() args = parser.parse_args()
# Process a single file if provided, otherwise process a directory # Process a single file if provided, otherwise process a directory