Files
audio_splitter/audio_splitter/main.py
T
max 8b67de59a3 Fix #29: validate --container against known containers
Before this fix, passing an invalid container name (e.g., 'nonexistent')
was passed directly to FFmpeg, which would fail with a cryptic error.
Now the container name is validated against CONTAINER_NAMES before any
processing begins, and a clear error message listing valid containers
is shown.
2026-09-02 15:41:38 +05:00

278 lines
8.0 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
"""
Audio Splitter CommandLine Interface
Split an audio file into tracks using a tracklist file.
Usage:
audio_splitter input.mp3 tracklist.txt [OPTIONS]
"""
import os
import sys
import subprocess
import argparse
from .defaults import (
DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE,
DEFAULT_REPLACEMENT_CHAR,
DEFAULT_BAD_CHARS,
DEFAULT_TRACKLIST_FORMAT,
DEFAULT_ALBUM,
DEFAULT_COMMENT,
DEFAULT_COMMENT_STREAM,
DEFAULT_COMMENT_SEPARATOR,
DEFAULT_NO_COMMENT,
DEFAULT_MERGE_COMMENTS,
DEFAULT_DROP_VIDEO,
DEFAULT_DROP_SUBS,
DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING,
DEFAULT_DELETE_ORIGINAL,
)
from .constants import CONTAINER_NAMES
from .core import split_audio
from .tracklist import read_tracklist, parse_format
from .ffmpeg import has_cover_or_video, validate_cover_images
def main():
parser = argparse.ArgumentParser(
description="Split an audio file into tracks using a tracklist.",
epilog="Tracklist format: mm:ss track_name - author_name (or custom with --tracklist-format)"
)
# Positional
parser.add_argument('input_file', help='Input audio file')
parser.add_argument('tracklist_file', help='Tracklist file')
# Container and codec options
parser.add_argument(
'--container',
default=None,
help="Output container format (auto-detect if not specified)"
)
parser.add_argument(
'--audio-codec',
default='copy',
help="Audio codec (copy or encoder name, e.g., libopus)"
)
parser.add_argument(
'--video-codec',
default='copy',
help="Video codec (copy or encoder name, e.g., libx264)"
)
parser.add_argument(
'--subtitle-codec',
default='copy',
help="Subtitle codec (copy or encoder name, e.g., srt)"
)
parser.add_argument(
'--video-quality', '-vq',
type=int,
default=None,
help="Video quality (integer, encoder-specific; usually 1-31, lower=better). "
"If omitted, FFmpeg default is used."
)
parser.add_argument(
'--drop-video',
action='store_true',
default=DEFAULT_DROP_VIDEO,
help="Drop video streams"
)
parser.add_argument(
'--drop-subs',
action='store_true',
default=DEFAULT_DROP_SUBS,
help="Drop subtitle streams"
)
# Filename options
parser.add_argument(
'--number-tracks',
action='store_true',
default=DEFAULT_NUMBER_TRACKS,
help="Prepend track numbers"
)
parser.add_argument(
'--output-template',
default=DEFAULT_OUTPUT_TEMPLATE,
help="Output filename template (default: %(default)s)"
)
parser.add_argument(
'--replace-bad-chars',
action='store_true',
default=DEFAULT_REPLACE_BAD_CHARS,
help="Replace bad characters"
)
parser.add_argument(
'--replacement-char',
default=DEFAULT_REPLACEMENT_CHAR,
help="Replacement character (default: %(default)s)"
)
parser.add_argument(
'--bad-chars',
default=DEFAULT_BAD_CHARS,
help="Bad characters to replace (default: %(default)s)"
)
parser.add_argument(
'--skip-existing',
action='store_true',
default=DEFAULT_SKIP_EXISTING,
help="Skip existing output files"
)
# Metadata
parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name")
parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment")
parser.add_argument(
'--no-comment',
action='store_true',
default=DEFAULT_NO_COMMENT,
help="Ignore comment"
)
parser.add_argument(
'--comment-stream',
type=int,
default=DEFAULT_COMMENT_STREAM,
help="Comment stream index"
)
parser.add_argument(
'--merge-comments',
action='store_true',
default=DEFAULT_MERGE_COMMENTS,
help="Merge all comments"
)
parser.add_argument(
'--comment-separator',
default=DEFAULT_COMMENT_SEPARATOR,
help="Separator for merged comments (default: %(default)s)"
)
# Tracklist format
parser.add_argument(
'--tracklist-format',
default=DEFAULT_TRACKLIST_FORMAT,
help="Tracklist format (default: %(default)s)"
)
# Cover image
parser.add_argument(
'--cover-image',
nargs='+',
metavar='IMAGE',
default=None,
help="Cover image path(s). Single image applied to all tracks, "
"or one per track (must match track count)."
)
# Other
parser.add_argument(
'--delete-original',
action='store_true',
default=DEFAULT_DELETE_ORIGINAL,
help="Delete original file after split"
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Parse and display tracklist without splitting"
)
parser.add_argument(
'--output-dir', '-o',
default=None,
help="Output directory (default: <input_basename>_splits)"
)
args = parser.parse_args()
# Validate input
if not os.path.exists(args.input_file):
print(f"Error: Input file not found: {args.input_file}")
sys.exit(1)
if not os.path.exists(args.tracklist_file):
print(f"Error: Tracklist file not found: {args.tracklist_file}")
sys.exit(1)
# Validate container if provided
if args.container and args.container not in CONTAINER_NAMES:
print(f"Error: Invalid container '{args.container}'. "
f"Valid containers: {', '.join(sorted(CONTAINER_NAMES))}.")
sys.exit(1)
# Check FFmpeg
try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
print("Error: FFmpeg not found. Please install FFmpeg first.")
print(" - https://ffmpeg.org/download.html")
sys.exit(1)
# Parse tracklist format
try:
tokens = parse_format(args.tracklist_format)
except ValueError as e:
print(f"Error in --tracklist-format: {e}")
sys.exit(1)
# Read tracklist
tracks = read_tracklist(args.tracklist_file, tokens)
if not tracks:
print("Error: No valid tracks found in tracklist file.")
sys.exit(1)
print(f"Found {len(tracks)} tracks.")
# Validate cover images if provided
if args.cover_image:
# Check if input already has cover image or video track
# Allow if --drop-video is specified (user wants to discard existing video)
if has_cover_or_video(args.input_file) and not args.drop_video:
print("Error: Input file already contains a cover image or video track. "
"Remove it first, or use --drop-video to discard video streams.")
sys.exit(1)
try:
validate_cover_images(args.cover_image, len(tracks))
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
if args.dry_run:
print("\nParsed tracklist:")
print("-" * 60)
if tracks:
fields = sorted(tracks[0].keys())
header = " | ".join(f"{f:>10}" for f in fields)
print(header)
print("-" * len(header))
for idx, track in enumerate(tracks, 1):
values = [f"{track.get(f, ''):>10}" for f in fields]
print(f"{idx:3d} | " + " | ".join(values))
print("-" * 60)
print("Dryrun complete. No files were created.")
return
# Determine output directory
if args.output_dir:
output_dir = args.output_dir
else:
base_name = os.path.splitext(os.path.basename(args.input_file))[0]
output_dir = base_name + "_splits"
# Run split
try:
split_audio(args.input_file, output_dir, tracks, args)
except Exception as e:
print(f"Error during split: {e}")
sys.exit(1)
print("Done!")
if __name__ == "__main__":
main()