8b67de59a3
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.
278 lines
8.0 KiB
Python
278 lines
8.0 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Audio Splitter – Command‑Line 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("Dry‑run 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()
|