Compare commits
29 Commits
4843ac1cb5
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ab85b64ff | |||
| cf0b9f62cb | |||
| 1b0767af90 | |||
| a8b0c538ff | |||
| 3a41bd2920 | |||
| 78ec4dd63a | |||
| 8b67de59a3 | |||
| 25e8c0293e | |||
| e36c1d69ed | |||
| e77434cf30 | |||
| 90d73d0e74 | |||
| 7a7b63b03b | |||
| a67acc31b7 | |||
| 5f68eccdbf | |||
| 156dfa693f | |||
| 580837fa73 | |||
| 747a349877 | |||
| 6f6637a9a2 | |||
| 5e02184c8f | |||
| e935241fd8 | |||
| 8e0d345ba7 | |||
| 3533525df0 | |||
| 834239674a | |||
| 748532be7c | |||
| f3abbb8f0c | |||
| 690c5f98c6 | |||
| 262d458ce4 | |||
| 7af2600ed8 | |||
| d08045bf02 |
+121
-17
@@ -1,20 +1,124 @@
|
||||
"""Global constants and default values."""
|
||||
# audio_splitter/constants.py
|
||||
"""Global constants for the audio splitter.
|
||||
|
||||
# Mapping from user‑friendly format names to FFmpeg format identifiers,
|
||||
# file extensions, and whether the container is audio‑only.
|
||||
FORMAT_INFO = {
|
||||
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
|
||||
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
|
||||
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
|
||||
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
|
||||
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
|
||||
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
|
||||
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
|
||||
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
|
||||
This file serves as the single source of truth for:
|
||||
- Container formats and their properties
|
||||
- Audio codecs and their recommended containers
|
||||
- Video codec support per container
|
||||
- File extensions for each codec/container combination
|
||||
- Compatibility matrix for codec/container validation
|
||||
|
||||
Codec != Container != File Extension.
|
||||
Example: Opus (codec) → Ogg (container) → .opus (extension)
|
||||
"""
|
||||
from typing import Dict, Optional
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Container information
|
||||
# ------------------------------------------------------------------------------
|
||||
CONTAINER_INFO = [
|
||||
# Audio-only containers
|
||||
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'supports_video': True, 'supports_subs': False},
|
||||
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'supports_video': False, 'supports_subs': False},
|
||||
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'supports_video': False, 'supports_subs': False},
|
||||
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'supports_video': False, 'supports_subs': False},
|
||||
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'supports_video': False, 'supports_subs': False},
|
||||
# Containers that support video and subtitles
|
||||
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'supports_video': True, 'supports_subs': True},
|
||||
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'supports_video': True, 'supports_subs': True},
|
||||
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'supports_video': True, 'supports_subs': True},
|
||||
{'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'supports_video': True, 'supports_subs': False},
|
||||
]
|
||||
|
||||
# Container name lookup set for fast membership checks
|
||||
CONTAINER_NAMES = {container['name'] for container in CONTAINER_INFO}
|
||||
|
||||
|
||||
def get_container_info(container_name: str) -> Optional[Dict]:
|
||||
"""Return container info dict or None if not found."""
|
||||
for container in CONTAINER_INFO:
|
||||
if container['name'] == container_name:
|
||||
return container
|
||||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Codec information
|
||||
# ------------------------------------------------------------------------------
|
||||
CODEC_INFO = [
|
||||
# Audio codecs
|
||||
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'codec_type': 'audio'},
|
||||
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'codec_type': 'audio'},
|
||||
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'codec_type': 'audio'},
|
||||
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'codec_type': 'audio'},
|
||||
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'codec_type': 'audio'},
|
||||
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'codec_type': 'audio'},
|
||||
{'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'codec_type': 'audio'},
|
||||
# Video codecs (including cover images)
|
||||
{'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'codec_type': 'video'},
|
||||
{'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'codec_type': 'video'},
|
||||
{'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'codec_type': 'video'},
|
||||
{'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'codec_type': 'video'},
|
||||
{'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'codec_type': 'video'},
|
||||
{'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'codec_type': 'video'},
|
||||
{'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'codec_type': 'video'},
|
||||
{'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'codec_type': 'video'},
|
||||
# Subtitle codecs
|
||||
{'name': 'srt', 'ffmpeg': 'srt', 'recommended_container': 'mkv', 'recommended_extension': '.srt', 'codec_type': 'subtitle'},
|
||||
{'name': 'ass', 'ffmpeg': 'ass', 'recommended_container': 'mkv', 'recommended_extension': '.ass', 'codec_type': 'subtitle'},
|
||||
{'name': 'vtt', 'ffmpeg': 'webvtt', 'recommended_container': 'webm', 'recommended_extension': '.vtt', 'codec_type': 'subtitle'},
|
||||
]
|
||||
|
||||
# Map codec name → recommended container
|
||||
CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO}
|
||||
CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO}
|
||||
|
||||
# Map codec name → FFmpeg encoder name
|
||||
CODEC_NAME_TO_FFMPEG = {codec['name']: codec['ffmpeg'] for codec in CODEC_INFO}
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Compatibility matrix: container → supported audio and video codecs
|
||||
# ------------------------------------------------------------------------------
|
||||
# Each container entry maps to a dict with 'audio' and 'video' keys.
|
||||
# - 'audio': list of audio codec names that are supported in this container.
|
||||
# Use None to indicate that any audio codec is supported.
|
||||
# - 'video': list of video codec names that are supported in this container.
|
||||
# Use None to indicate that any video codec is supported.
|
||||
# Empty list means no video support (audio-only container).
|
||||
COMPATIBILITY_MATRIX = {
|
||||
'mp3': {'audio': ['mp3'], 'video': ['png', 'mjpeg']},
|
||||
'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []},
|
||||
'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']},
|
||||
'mkv': {'audio': None, 'video': None},
|
||||
'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8', 'png', 'mjpeg']},
|
||||
'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']},
|
||||
'flac': {'audio': ['flac'], 'video': ['png', 'mjpeg']},
|
||||
'wav': {'audio': ['pcm_s16le'], 'video': []},
|
||||
'aac': {'audio': ['aac'], 'video': []},
|
||||
}
|
||||
|
||||
# Default characters to replace when --replace-bad-chars is enabled.
|
||||
# Includes common punctuation, single quote, and a trailing space.
|
||||
DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' '
|
||||
# ------------------------------------------------------------------------------
|
||||
# Helper functions for compatibility checking
|
||||
# ------------------------------------------------------------------------------
|
||||
def is_audio_codec_supported(container: str, audio_codec: str) -> bool:
|
||||
"""Check if an audio codec is supported in the given container."""
|
||||
entry = COMPATIBILITY_MATRIX.get(container, {})
|
||||
supported = entry.get('audio')
|
||||
if supported is None:
|
||||
return True
|
||||
return audio_codec in supported
|
||||
|
||||
|
||||
def is_video_codec_supported(container: str, video_codec: str) -> bool:
|
||||
"""Check if a video codec is supported in the given container."""
|
||||
entry = COMPATIBILITY_MATRIX.get(container, {})
|
||||
supported = entry.get('video')
|
||||
if supported is None:
|
||||
return True
|
||||
return video_codec in supported
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Default bad characters (for filename sanitization)
|
||||
# ------------------------------------------------------------------------------
|
||||
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\'|," '
|
||||
|
||||
+204
-29
@@ -1,36 +1,87 @@
|
||||
"""Core logic: orchestrates the splitting process."""
|
||||
# audio_splitter/core.py
|
||||
"""Core splitting logic – orchestrates the entire split process."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from .constants import FORMAT_INFO
|
||||
from .ffmpeg import get_audio_duration, get_stream_info, get_metadata, build_ffmpeg_command
|
||||
from .formats import determine_output_format, validate_format_compatibility
|
||||
from .constants import (
|
||||
CONTAINER_INFO,
|
||||
CONTAINER_NAMES,
|
||||
CODEC_TO_EXTENSION_MAP,
|
||||
CODEC_NAME_TO_FFMPEG,
|
||||
)
|
||||
from .ffmpeg import (
|
||||
get_audio_duration,
|
||||
get_stream_info,
|
||||
get_metadata,
|
||||
build_ffmpeg_command,
|
||||
is_attached_picture,
|
||||
extract_cover_image,
|
||||
)
|
||||
from .formats import (
|
||||
determine_output_format,
|
||||
validate_format_compatibility,
|
||||
)
|
||||
from .handlers import get_handler, needs_drop_video
|
||||
from .timestamp import parse_track_timestamps, resolve_end_times
|
||||
from .filename import build_filename
|
||||
from .metadata import build_metadata_dict
|
||||
from .utils import format_time
|
||||
|
||||
|
||||
def split_audio(input_file, output_directory, tracks, args):
|
||||
def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, Any]], args) -> None:
|
||||
"""
|
||||
Main orchestration function: split the audio file into tracks.
|
||||
|
||||
Args:
|
||||
input_file: Path to the input media file.
|
||||
output_directory: Directory where output files will be saved.
|
||||
tracks: List of dicts, each containing parsed fields.
|
||||
args: Parsed command‑line arguments (namespace).
|
||||
tracks: List of dicts, each containing parsed fields (ts, tn, an, ...).
|
||||
args: Parsed command‑line arguments (namespace) with attributes:
|
||||
- container: output container name (or None for auto-detect)
|
||||
- audio_codec: audio codec (copy or encoder)
|
||||
- video_codec: video codec (copy or encoder)
|
||||
- subtitle_codec: subtitle codec (copy or encoder)
|
||||
- video_quality: integer or None
|
||||
- drop_video: bool
|
||||
- drop_subs: bool
|
||||
- number_tracks: bool
|
||||
- output_template: str
|
||||
- replace_bad_chars: bool
|
||||
- replacement_char: str
|
||||
- bad_chars: str
|
||||
- skip_existing: bool
|
||||
- album: str or None
|
||||
- comment: str or None
|
||||
- no_comment: bool
|
||||
- comment_stream: int or None
|
||||
- merge_comments: bool
|
||||
- comment_separator: str
|
||||
- delete_original: bool
|
||||
- cover_image: List[str] or None (optional, CLI list of cover image paths)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If no audio stream is found.
|
||||
ValueError: If timestamp parsing or format compatibility fails.
|
||||
ValueError: If compatibility validation fails.
|
||||
"""
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. Setup
|
||||
# --------------------------------------------------------------------------
|
||||
os.makedirs(output_directory, exist_ok=True)
|
||||
|
||||
# Check write permission on output directory.
|
||||
try:
|
||||
test_file = os.path.join(output_directory, f'.write_test_{os.getpid()}')
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test')
|
||||
os.remove(test_file)
|
||||
except PermissionError:
|
||||
raise RuntimeError(
|
||||
f"Error: No write permission for output directory: {output_directory}"
|
||||
)
|
||||
|
||||
total_duration = get_audio_duration(input_file)
|
||||
stream_info = get_stream_info(input_file)
|
||||
|
||||
@@ -42,26 +93,62 @@ def split_audio(input_file, output_directory, tracks, args):
|
||||
raise RuntimeError("No audio stream found in input file.")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Format decision
|
||||
# 2. Determine output container
|
||||
# --------------------------------------------------------------------------
|
||||
output_format = determine_output_format(stream_info, args.format, args.transcode_to, input_file=input_file)
|
||||
print(f"Output container: {output_format}")
|
||||
|
||||
validate_format_compatibility(output_format, stream_info,
|
||||
args.drop_video, args.drop_subs)
|
||||
|
||||
# Determine file extension.
|
||||
extension_info = FORMAT_INFO.get(output_format, {})
|
||||
extension = extension_info.get('ext', '.mkv')
|
||||
user_container = getattr(args, 'container', None)
|
||||
output_container = determine_output_format(
|
||||
stream_info,
|
||||
user_format=user_container,
|
||||
transcode_audio=args.audio_codec if args.audio_codec != 'copy' else None,
|
||||
input_file=input_file
|
||||
)
|
||||
print(f"Output container: {output_container}")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Parse timestamps
|
||||
# 3. Validate compatibility
|
||||
# --------------------------------------------------------------------------
|
||||
try:
|
||||
validate_format_compatibility(
|
||||
container=output_container,
|
||||
stream_info=stream_info,
|
||||
drop_video=args.drop_video,
|
||||
drop_subs=args.drop_subs,
|
||||
input_file=input_file,
|
||||
audio_codec=args.audio_codec,
|
||||
video_codec=args.video_codec,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f"Compatibility error: {e}")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Determine output audio codec and extension
|
||||
# --------------------------------------------------------------------------
|
||||
# Determine the audio codec that will be used in the output
|
||||
if args.audio_codec != 'copy':
|
||||
output_audio_codec = args.audio_codec
|
||||
else:
|
||||
output_audio_codec = stream_info.get('audio_codec', '')
|
||||
|
||||
# Choose extension based on the output container, not the audio codec.
|
||||
# When user specifies --container, the file extension must match the container.
|
||||
container_info = next((c for c in CONTAINER_INFO if c['name'] == output_container), None)
|
||||
if container_info:
|
||||
extension = container_info['extension']
|
||||
elif output_audio_codec in CODEC_TO_EXTENSION_MAP:
|
||||
extension = CODEC_TO_EXTENSION_MAP[output_audio_codec]
|
||||
else:
|
||||
extension = '.mkv'
|
||||
|
||||
print(f"Output extension: {extension}")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. Parse timestamps
|
||||
# --------------------------------------------------------------------------
|
||||
track_times = parse_track_timestamps(tracks)
|
||||
resolved_times = resolve_end_times(track_times, total_duration)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Fetch original metadata (for fallbacks)
|
||||
# 6. Fetch original metadata (for fallbacks)
|
||||
# --------------------------------------------------------------------------
|
||||
input_metadata = get_metadata(input_file)
|
||||
original_album = input_metadata.get('album')
|
||||
@@ -78,7 +165,39 @@ def split_audio(input_file, output_directory, tracks, args):
|
||||
print(f" Stream {idx}: '{comment}'")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. Process each track
|
||||
# 7. Handle attached picture (cover art)
|
||||
# --------------------------------------------------------------------------
|
||||
user_cover_images = getattr(args, 'cover_image', None)
|
||||
# Resolve per-track cover images: single image reused, or one-per-track
|
||||
track_cover_images = []
|
||||
if user_cover_images:
|
||||
for img in user_cover_images:
|
||||
if os.path.exists(img):
|
||||
track_cover_images.append(img)
|
||||
else:
|
||||
print(f"Warning: Cover image not found, skipping: {img}")
|
||||
# Extract cover from input if no user cover and input has video
|
||||
if not track_cover_images and not args.drop_video and stream_info.get('has_video'):
|
||||
if is_attached_picture(input_file):
|
||||
cover_image_path = os.path.join(output_directory, 'cover.png')
|
||||
if extract_cover_image(input_file, cover_image_path):
|
||||
track_cover_images.append(cover_image_path)
|
||||
print("Extracted cover image for all tracks.")
|
||||
else:
|
||||
track_cover_images = []
|
||||
|
||||
# Check if we need special handling (e.g., Opus files need opustags)
|
||||
# Use output_audio_codec (not input_audio_codec) so the handler is resolved
|
||||
# against the actual output codec (e.g., transcoding aac→opus in ogg).
|
||||
input_audio_codec = stream_info.get('audio_codec')
|
||||
cover_handler = get_handler(output_container, output_audio_codec)
|
||||
needs_drop = needs_drop_video(output_container, output_audio_codec)
|
||||
if track_cover_images and needs_drop:
|
||||
print(f"Using special handler for {output_container} + {output_audio_codec}")
|
||||
print("Temporarily dropping video for opustags post-processing.")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 8. Process each track
|
||||
# --------------------------------------------------------------------------
|
||||
for idx, track in enumerate(tracks, start=1):
|
||||
start_seconds, end_seconds = resolved_times[idx - 1]
|
||||
@@ -91,31 +210,62 @@ def split_audio(input_file, output_directory, tracks, args):
|
||||
track_name = track.get('tn', 'Unknown')
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 5a. Build filename
|
||||
# 8a. Build filename
|
||||
# ----------------------------------------------------------------------
|
||||
clean_filename = build_filename(track, idx, extension, args)
|
||||
output_path = os.path.join(output_directory, clean_filename)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 5b. Handle existing files
|
||||
# 8b. Handle existing files
|
||||
# ----------------------------------------------------------------------
|
||||
if args.skip_existing and os.path.exists(output_path):
|
||||
print(f"Skipping track {idx}: {output_path} already exists.")
|
||||
continue
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 5c. Build metadata
|
||||
# 8c. Build metadata
|
||||
# ----------------------------------------------------------------------
|
||||
metadata = build_metadata_dict(track, idx, input_metadata, args)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 5d. Build and execute FFmpeg command
|
||||
# 8d. Build and execute FFmpeg command
|
||||
# ----------------------------------------------------------------------
|
||||
# Map codec names to FFmpeg encoder names
|
||||
audio_enc = args.audio_codec
|
||||
if audio_enc != 'copy':
|
||||
audio_enc = CODEC_NAME_TO_FFMPEG.get(audio_enc, audio_enc) # fallback to itself if not found
|
||||
video_enc = args.video_codec
|
||||
if video_enc != 'copy':
|
||||
video_enc = CODEC_NAME_TO_FFMPEG.get(video_enc, video_enc)
|
||||
subtitle_enc = args.subtitle_codec
|
||||
if subtitle_enc != 'copy':
|
||||
subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc)
|
||||
|
||||
# Then build command with these mapped encoders
|
||||
# Use temporary drop_video for handlers that need it
|
||||
effective_drop_video = args.drop_video or (track_cover_images and needs_drop)
|
||||
# For handlers that need opustags post-processing, don't pass cover_image_path
|
||||
# to ffmpeg - it will process audio-only, then handler adds cover afterward
|
||||
per_track_cover = (
|
||||
track_cover_images[idx - 1] if len(track_cover_images) == len(tracks)
|
||||
else (track_cover_images[0] if track_cover_images else None)
|
||||
)
|
||||
ffmpeg_cover_path = None if (per_track_cover and needs_drop) else per_track_cover
|
||||
cmd = build_ffmpeg_command(
|
||||
input_file, start_seconds, duration_seconds, output_path,
|
||||
stream_info, output_format, args.transcode_to,
|
||||
args.drop_video, args.drop_subs,
|
||||
metadata=metadata
|
||||
input_file=input_file,
|
||||
start_seconds=start_seconds,
|
||||
duration_seconds=duration_seconds,
|
||||
output_path=output_path,
|
||||
stream_info=stream_info,
|
||||
format_opt=output_container,
|
||||
audio_codec=audio_enc,
|
||||
video_codec=video_enc,
|
||||
subtitle_codec=subtitle_enc,
|
||||
metadata=metadata,
|
||||
cover_image_path=ffmpeg_cover_path,
|
||||
video_quality=getattr(args, 'video_quality', None),
|
||||
drop_video=effective_drop_video,
|
||||
drop_subs=args.drop_subs,
|
||||
)
|
||||
|
||||
print(f"Extracting track {idx}: {track_name} "
|
||||
@@ -128,3 +278,28 @@ def split_audio(input_file, output_directory, tracks, args):
|
||||
print(result.stderr)
|
||||
else:
|
||||
print(f" -> Saved to: {output_path}")
|
||||
# Apply cover image handler if needed
|
||||
if per_track_cover and needs_drop:
|
||||
print(f"Applying cover image via {output_container} handler...")
|
||||
if not cover_handler(
|
||||
input_file=input_file,
|
||||
cover_image_path=per_track_cover,
|
||||
output_path=output_path,
|
||||
stream_info=stream_info,
|
||||
audio_codec=audio_enc,
|
||||
video_codec=video_enc,
|
||||
video_quality=getattr(args, 'video_quality', None),
|
||||
drop_video=args.drop_video,
|
||||
drop_subs=args.drop_subs,
|
||||
):
|
||||
print(f"Warning: Failed to attach cover image to {output_path}")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 9. Delete original file if requested
|
||||
# --------------------------------------------------------------------------
|
||||
if getattr(args, 'delete_original', False):
|
||||
try:
|
||||
os.remove(input_file)
|
||||
print(f"Deleted original file: {input_file}")
|
||||
except OSError as e:
|
||||
print(f"Warning: Could not delete original file: {e}")
|
||||
|
||||
+310
-53
@@ -1,10 +1,12 @@
|
||||
"""FFmpeg/FFprobe interaction utilities for the CLI and web backend."""
|
||||
# audio_splitter/ffmpeg.py
|
||||
"""FFmpeg/FFprobe interaction utilities."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .constants import FORMAT_INFO
|
||||
from .constants import CONTAINER_INFO
|
||||
from .utils import format_time
|
||||
|
||||
|
||||
@@ -75,6 +77,28 @@ def get_audio_codec(input_file: str) -> Optional[str]:
|
||||
return codec if codec else None
|
||||
|
||||
|
||||
def get_video_codec(input_file: str) -> Optional[str]:
|
||||
"""
|
||||
Return the codec name of the first video stream.
|
||||
|
||||
Args:
|
||||
input_file: Path to the media file.
|
||||
|
||||
Returns:
|
||||
Codec name as a lowercase string, or None if no video stream exists.
|
||||
"""
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
codec = result.stdout.strip().lower()
|
||||
return codec if codec else None
|
||||
|
||||
|
||||
def get_stream_info(input_file: str) -> Dict[str, any]:
|
||||
"""
|
||||
Collect information about the streams present in the input file.
|
||||
@@ -136,7 +160,7 @@ def get_metadata(input_file: str) -> Dict[str, any]:
|
||||
|
||||
def get_container_format(input_file: str) -> Optional[str]:
|
||||
"""
|
||||
Retrieve the container format name (e.g., 'mp4', 'mp3', 'matroska') from the input file.
|
||||
Retrieve the container format name (e.g., 'mp4', 'mp3', 'mkv') from the input file.
|
||||
|
||||
Args:
|
||||
input_file: Path to the media file.
|
||||
@@ -156,69 +180,237 @@ def get_container_format(input_file: str) -> Optional[str]:
|
||||
format_name = result.stdout.strip().split(',')[0] # take first if multiple
|
||||
if not format_name:
|
||||
return None
|
||||
# Normalize common aliases to names used in FORMAT_INFO (container only, not codec-specific)
|
||||
# This mapping is purely for container identification.
|
||||
# Normalize common aliases to names used in CONTAINER_INFO
|
||||
mapping = {
|
||||
'mpeg': 'mp3', # MPEG-1/2 audio (MP3) container
|
||||
'mpeg': 'mp3',
|
||||
'mp2': 'mp3',
|
||||
'mp4': 'mp4',
|
||||
'm4a': 'mp4', # M4A is MP4 container
|
||||
'mov': 'mp4', # QuickTime is MP4-like
|
||||
'm4a': 'mp4',
|
||||
'mov': 'mp4',
|
||||
'3gp': 'mp4',
|
||||
'matroska': 'matroska',
|
||||
'webm': 'matroska', # WebM uses Matroska container
|
||||
'matroska': 'mkv',
|
||||
'webm': 'mkv',
|
||||
'ogg': 'ogg',
|
||||
'flac': 'flac',
|
||||
'wav': 'wav',
|
||||
'aac': 'aac',
|
||||
'opus': 'opus',
|
||||
'mp3': 'mp3',
|
||||
'adts': 'aac', # raw AAC in ADTS container
|
||||
'amr': 'amr', # AMR container (rare)
|
||||
'adts': 'aac',
|
||||
'amr': 'amr',
|
||||
}
|
||||
return mapping.get(format_name, format_name)
|
||||
|
||||
|
||||
def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: int,
|
||||
output_path: str, stream_info: Dict, format_opt: Optional[str],
|
||||
transcode_audio: Optional[str], drop_video: bool, drop_subs: bool,
|
||||
metadata: Optional[Dict] = None) -> List[str]:
|
||||
"""
|
||||
Construct the FFmpeg command line as a list of arguments.
|
||||
MAX_COVER_IMAGE_SIZE_MB = 4
|
||||
|
||||
Args:
|
||||
input_file: Path to the input media file.
|
||||
start_seconds: Start time for the segment (in seconds).
|
||||
duration_seconds: Duration of the segment (in seconds).
|
||||
output_path: Destination path for the output file.
|
||||
stream_info: Dictionary from get_stream_info().
|
||||
format_opt: Output container format (e.g., 'mp3').
|
||||
transcode_audio: Audio codec to transcode to (or None).
|
||||
drop_video: True to remove video streams.
|
||||
drop_subs: True to remove subtitle streams.
|
||||
metadata: Optional dict of metadata key/value pairs to write.
|
||||
|
||||
def has_cover_or_video(input_file: str) -> bool:
|
||||
"""
|
||||
Check if the input file contains a cover image (attached picture) or any video track.
|
||||
|
||||
Returns:
|
||||
A list of command‑line arguments suitable for subprocess.run().
|
||||
True if a cover image or video stream is detected, False otherwise.
|
||||
"""
|
||||
cmd = [
|
||||
'ffmpeg',
|
||||
'-i', input_file,
|
||||
'-ss', format_time(start_seconds),
|
||||
'-t', format_time(duration_seconds)
|
||||
'ffprobe', '-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-select_streams', 'v',
|
||||
'-show_entries', 'stream=codec_type,codec_name,disposition',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
# Clear all original metadata.
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
streams = data.get('streams', [])
|
||||
return len(streams) > 0
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return False
|
||||
|
||||
|
||||
def validate_cover_images(cover_images: List[str], num_tracks: int) -> None:
|
||||
"""
|
||||
Validate cover image(s) before splitting.
|
||||
|
||||
Checks:
|
||||
- Each file exists
|
||||
- Only PNG and JPEG formats are accepted
|
||||
- Single image OR count matches number of tracks
|
||||
- File size warning for images > 4 MB
|
||||
|
||||
Args:
|
||||
cover_images: List of cover image paths (single image or multiple).
|
||||
num_tracks: Number of tracks in the tracklist.
|
||||
|
||||
Raises:
|
||||
ValueError: If validation fails.
|
||||
"""
|
||||
if len(cover_images) == 0:
|
||||
return
|
||||
|
||||
if len(cover_images) == 1 and num_tracks > 1:
|
||||
# Single image is fine - will be reused for all tracks
|
||||
pass
|
||||
elif len(cover_images) == num_tracks:
|
||||
pass
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Cover image count ({len(cover_images)}) must be 1 or match "
|
||||
f"the number of tracks ({num_tracks})."
|
||||
)
|
||||
|
||||
for img_path in cover_images:
|
||||
if not os.path.exists(img_path):
|
||||
raise ValueError(f"Cover image not found: {img_path}")
|
||||
|
||||
ext = os.path.splitext(img_path)[1].lower()
|
||||
if ext not in ('.png', '.jpg', '.jpeg'):
|
||||
raise ValueError(
|
||||
f"Unsupported cover image format '{ext}' for '{img_path}'. "
|
||||
f"Only PNG and JPEG are supported."
|
||||
)
|
||||
|
||||
size_mb = os.path.getsize(img_path) / (1024 * 1024)
|
||||
if size_mb > MAX_COVER_IMAGE_SIZE_MB:
|
||||
print(
|
||||
f"Warning: Cover image '{img_path}' is {size_mb:.1f} MB "
|
||||
f"(exceeds {MAX_COVER_IMAGE_SIZE_MB} MB limit). "
|
||||
f"Large images may cause issues."
|
||||
)
|
||||
|
||||
|
||||
def is_attached_picture(input_file: str) -> bool:
|
||||
"""
|
||||
Check if the input file has a video stream that is an attached picture (cover art).
|
||||
|
||||
Detection logic:
|
||||
1. If there is a video stream with disposition.attached_pic == 1, return True.
|
||||
2. Otherwise, if there is exactly one video stream and its codec is an image
|
||||
format (PNG, MJPEG, JPEG, GIF, BMP), return True.
|
||||
|
||||
Returns:
|
||||
True if a cover image is detected, False otherwise.
|
||||
"""
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-select_streams', 'v',
|
||||
'-show_entries', 'stream=codec_name,disposition',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
streams = data.get('streams', [])
|
||||
if not streams:
|
||||
return False
|
||||
|
||||
# Check each stream
|
||||
for stream in streams:
|
||||
codec = stream.get('codec_name', '').lower()
|
||||
disposition = stream.get('disposition', {})
|
||||
# If attached_pic is set, it's a cover image
|
||||
if disposition.get('attached_pic') == 1:
|
||||
return True
|
||||
# If not, check if it's an image codec and we have exactly one video stream
|
||||
if codec in ('png', 'mjpeg', 'jpeg', 'gif', 'bmp'):
|
||||
# If there is exactly one video stream, treat it as cover
|
||||
if len(streams) == 1:
|
||||
return True
|
||||
return False
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return False
|
||||
|
||||
|
||||
def extract_cover_image(input_file: str, output_path: str) -> bool:
|
||||
"""
|
||||
Extract the first frame of the video stream (assumed to be an attached picture)
|
||||
and save it to output_path.
|
||||
|
||||
Returns:
|
||||
True if extraction succeeded, False otherwise.
|
||||
"""
|
||||
cmd = [
|
||||
'ffmpeg', '-i', input_file,
|
||||
'-map', '0:v:0',
|
||||
'-frames:v', '1',
|
||||
'-y',
|
||||
output_path
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
print(f"Failed to extract cover image: {result.stderr}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_inputs(cmd: List[str], input_file: str, cover_image_path: Optional[str]) -> None:
|
||||
"""Add input files to the command."""
|
||||
if cover_image_path:
|
||||
cmd.extend(['-i', cover_image_path])
|
||||
cmd.extend(['-i', input_file])
|
||||
|
||||
|
||||
def _build_time_options(cmd: List[str], start_seconds: int, duration_seconds: int) -> None:
|
||||
"""Add time-based options to the command."""
|
||||
cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)])
|
||||
|
||||
|
||||
def _build_metadata(cmd: List[str], metadata: Optional[Dict]) -> None:
|
||||
"""Add metadata options to the command."""
|
||||
cmd.append('-map_metadata')
|
||||
cmd.append('-1')
|
||||
|
||||
# Apply custom metadata.
|
||||
if metadata:
|
||||
for key, value in metadata.items():
|
||||
if value is not None and value != '':
|
||||
cmd.extend(['-metadata', f"{key}={value}"])
|
||||
|
||||
# Stream mapping.
|
||||
|
||||
def _build_cover_image_mapping(
|
||||
cmd: List[str],
|
||||
audio_codec: Optional[str],
|
||||
video_codec: Optional[str],
|
||||
video_quality: Optional[int],
|
||||
) -> None:
|
||||
"""Build stream mapping for cover image extraction (audio + cover video)."""
|
||||
cmd.extend(['-map', '1:a:0', '-map', '0:v:0'])
|
||||
|
||||
if video_codec and video_codec != 'copy':
|
||||
cmd.extend(['-c:v', video_codec])
|
||||
else:
|
||||
cmd.extend(['-c:v', 'png'])
|
||||
|
||||
if audio_codec and audio_codec != 'copy':
|
||||
cmd.extend(['-c:a', audio_codec])
|
||||
else:
|
||||
cmd.extend(['-c:a', 'copy'])
|
||||
|
||||
# Preserve attached_pic disposition for cover image
|
||||
cmd.extend(['-disposition', 'attached_pic'])
|
||||
|
||||
cmd.append('-sn')
|
||||
|
||||
if video_quality is not None:
|
||||
cmd.extend(['-q:v', str(video_quality)])
|
||||
|
||||
|
||||
def _build_standard_mapping(
|
||||
cmd: List[str],
|
||||
stream_info: Dict,
|
||||
audio_codec: Optional[str],
|
||||
video_codec: Optional[str],
|
||||
subtitle_codec: Optional[str],
|
||||
video_quality: Optional[int],
|
||||
drop_video: bool,
|
||||
drop_subs: bool,
|
||||
) -> None:
|
||||
"""Build stream mapping for standard extraction (no cover image)."""
|
||||
if drop_video and drop_subs:
|
||||
cmd.extend(['-map', '0:a:0'])
|
||||
elif drop_video:
|
||||
@@ -228,32 +420,97 @@ def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds:
|
||||
else:
|
||||
cmd.extend(['-map', '0'])
|
||||
|
||||
# Audio codec.
|
||||
if transcode_audio:
|
||||
cmd.extend(['-c:a', transcode_audio])
|
||||
if transcode_audio in ('libmp3lame', 'mp3'):
|
||||
cmd.extend(['-b:a', '192k'])
|
||||
elif transcode_audio in ('libopus', 'opus'):
|
||||
cmd.extend(['-b:a', '128k'])
|
||||
if audio_codec and audio_codec != 'copy':
|
||||
cmd.extend(['-c:a', audio_codec])
|
||||
else:
|
||||
cmd.extend(['-c:a', 'copy'])
|
||||
|
||||
# Video codec.
|
||||
if not drop_video and stream_info['has_video']:
|
||||
cmd.extend(['-c:v', 'copy'])
|
||||
if not drop_video and stream_info.get('has_video'):
|
||||
if video_codec and video_codec != 'copy':
|
||||
cmd.extend(['-c:v', video_codec])
|
||||
if video_quality is not None:
|
||||
cmd.extend(['-q:v', str(video_quality)])
|
||||
else:
|
||||
cmd.extend(['-c:v', 'copy'])
|
||||
else:
|
||||
cmd.append('-vn')
|
||||
|
||||
# Subtitle codec.
|
||||
if not drop_subs and stream_info['has_subtitle']:
|
||||
cmd.extend(['-c:s', 'copy'])
|
||||
if not drop_subs and stream_info.get('has_subtitle'):
|
||||
if subtitle_codec and subtitle_codec != 'copy':
|
||||
cmd.extend(['-c:s', subtitle_codec])
|
||||
else:
|
||||
cmd.extend(['-c:s', 'copy'])
|
||||
else:
|
||||
cmd.append('-sn')
|
||||
|
||||
# Output format.
|
||||
|
||||
def _build_format(cmd: List[str], format_opt: Optional[str]) -> None:
|
||||
"""Add output format option if specified."""
|
||||
if format_opt:
|
||||
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
|
||||
ffmpeg_format = next((c['ffmpeg'] for c in CONTAINER_INFO if c['name'] == format_opt), format_opt)
|
||||
cmd.extend(['-f', ffmpeg_format])
|
||||
|
||||
|
||||
def _build_output(cmd: List[str], output_path: str) -> None:
|
||||
"""Add output file to the command."""
|
||||
cmd.extend(['-y', output_path])
|
||||
|
||||
|
||||
def build_ffmpeg_command(
|
||||
input_file: str,
|
||||
start_seconds: int,
|
||||
duration_seconds: int,
|
||||
output_path: str,
|
||||
stream_info: Dict,
|
||||
format_opt: Optional[str],
|
||||
audio_codec: Optional[str] = 'copy',
|
||||
video_codec: Optional[str] = 'copy',
|
||||
subtitle_codec: Optional[str] = 'copy',
|
||||
metadata: Optional[Dict] = None,
|
||||
cover_image_path: Optional[str] = None,
|
||||
video_quality: Optional[int] = None,
|
||||
drop_video: bool = False,
|
||||
drop_subs: bool = False,
|
||||
) -> List[str]:
|
||||
"""
|
||||
Construct the FFmpeg command line as a list of arguments.
|
||||
|
||||
Args:
|
||||
input_file: Path to the input media file.
|
||||
start_seconds: Start time for the segment (in seconds).
|
||||
duration_seconds: Duration of the segment (in seconds).
|
||||
output_path: Destination path for the output file.
|
||||
stream_info: Dictionary from get_stream_info().
|
||||
format_opt: Output container format (e.g., 'mp3', 'mkv').
|
||||
audio_codec: Audio codec to use ('copy' or encoder name like 'libopus').
|
||||
video_codec: Video codec to use ('copy' or encoder name).
|
||||
subtitle_codec: Subtitle codec to use ('copy' or encoder name).
|
||||
metadata: Optional dict of metadata key/value pairs to write.
|
||||
cover_image_path: Path to extracted cover image (if any).
|
||||
video_quality: Quality value for video encoder (e.g., 1-31, lower=better).
|
||||
drop_video: If True, remove video streams.
|
||||
drop_subs: If True, remove subtitle streams.
|
||||
|
||||
Returns:
|
||||
A list of command‑line arguments suitable for subprocess.run().
|
||||
"""
|
||||
cmd = ['ffmpeg']
|
||||
|
||||
_build_inputs(cmd, input_file, cover_image_path)
|
||||
_build_time_options(cmd, start_seconds, duration_seconds)
|
||||
_build_metadata(cmd, metadata)
|
||||
|
||||
if cover_image_path:
|
||||
_build_cover_image_mapping(cmd, audio_codec, video_codec, video_quality)
|
||||
else:
|
||||
_build_standard_mapping(
|
||||
cmd, stream_info, audio_codec, video_codec, subtitle_codec,
|
||||
video_quality, drop_video, drop_subs
|
||||
)
|
||||
|
||||
_build_format(cmd, format_opt)
|
||||
_build_output(cmd, output_path)
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,18 @@ def build_filename(track, idx, extension, args):
|
||||
clean_filename = apply_replacement(raw_filename,
|
||||
args.bad_chars,
|
||||
args.replacement_char)
|
||||
clean_filename = cleanup_good_chars(clean_filename, args.replacement_char)
|
||||
# Split off the file extension before cleanup so trailing
|
||||
# replacement chars don't leak into the name portion.
|
||||
name_part, _, ext_part = clean_filename.rpartition('.')
|
||||
clean_filename = cleanup_good_chars(name_part, args.replacement_char)
|
||||
# Strip leading/trailing separator characters (replacement char,
|
||||
# hyphen, etc.) that may result from empty fields or bad-char
|
||||
# sequences adjacent to template separators.
|
||||
clean_filename = clean_filename.strip(f'{args.replacement_char}-')
|
||||
# Fallback: if cleanup leaves an empty name, use the track number.
|
||||
if not clean_filename:
|
||||
clean_filename = f"{idx:02d}"
|
||||
clean_filename = f"{clean_filename}.{ext_part}"
|
||||
else:
|
||||
clean_filename = raw_filename
|
||||
# Warn about unsafe characters.
|
||||
|
||||
+74
-67
@@ -1,19 +1,28 @@
|
||||
# audio_splitter/formats.py
|
||||
"""Container format decision and validation."""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .constants import FORMAT_INFO
|
||||
from .constants import (
|
||||
CONTAINER_NAMES,
|
||||
CONTAINER_INFO,
|
||||
CODEC_TO_CONTAINER_MAP,
|
||||
COMPATIBILITY_MATRIX,
|
||||
is_audio_codec_supported,
|
||||
is_video_codec_supported,
|
||||
)
|
||||
from .defaults import DEFAULT_FORMAT
|
||||
|
||||
|
||||
def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Given the container format and audio codec, determine the recommended output format.
|
||||
|
||||
This is used when the user has not explicitly specified a format.
|
||||
It prioritizes the codec to choose the most appropriate container/extension.
|
||||
Uses the CODEC_TO_CONTAINER_MAP to map codec → container.
|
||||
If the codec is not found, it falls back to the container.
|
||||
|
||||
Args:
|
||||
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format().
|
||||
container: Container name (e.g., 'ogg', 'mp4', 'mkv') as returned by get_container_format().
|
||||
codec: Audio codec name (e.g., 'opus', 'aac', 'mp3') as returned by get_audio_codec().
|
||||
|
||||
Returns:
|
||||
@@ -22,37 +31,13 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) ->
|
||||
if not container:
|
||||
return None
|
||||
|
||||
# Codec-based decisions (highest priority)
|
||||
if codec == 'opus':
|
||||
return 'opus'
|
||||
if codec in ('aac', 'alac', 'he-aac'):
|
||||
return 'm4a'
|
||||
if codec == 'mp3':
|
||||
return 'mp3'
|
||||
if codec == 'vorbis':
|
||||
return 'ogg'
|
||||
if codec == 'flac':
|
||||
return 'flac'
|
||||
if codec and codec in CODEC_TO_CONTAINER_MAP:
|
||||
fmt = CODEC_TO_CONTAINER_MAP[codec]
|
||||
if fmt in CONTAINER_NAMES:
|
||||
return fmt
|
||||
|
||||
# Container-based fallback (lower priority)
|
||||
if container in ('mp4', 'm4a', 'mov', '3gp'):
|
||||
return 'mp4'
|
||||
if container in ('matroska', 'webm'):
|
||||
return 'matroska'
|
||||
if container in ('ogg',):
|
||||
return 'ogg'
|
||||
if container in ('mp3', 'mpeg'):
|
||||
return 'mp3'
|
||||
if container == 'flac':
|
||||
return 'flac'
|
||||
if container == 'wav':
|
||||
return 'wav'
|
||||
if container == 'aac':
|
||||
return 'aac'
|
||||
if container == 'opus':
|
||||
return 'opus'
|
||||
if container == 'amr':
|
||||
return 'amr'
|
||||
if container in CONTAINER_NAMES:
|
||||
return container
|
||||
|
||||
return None
|
||||
|
||||
@@ -62,69 +47,91 @@ def determine_output_format(stream_info: Dict, user_format: Optional[str],
|
||||
"""
|
||||
Decide which container format to use.
|
||||
|
||||
If user_format is provided, use it.
|
||||
If user_format is provided, map it to the correct container if it's a codec name.
|
||||
Else, try to detect the input file's container and codec, and use the recommended format.
|
||||
If detection fails or format is not supported, fallback to:
|
||||
- MKV if video/subtitles exist
|
||||
- MP3 if the audio codec is MP3
|
||||
- MP4 (M4A) otherwise
|
||||
|
||||
Args:
|
||||
stream_info: Dict from get_stream_info().
|
||||
user_format: User‑requested format (or None).
|
||||
transcode_audio: Audio codec to transcode to (or None) (unused in this function).
|
||||
input_file: Path to the input file (optional, used to detect container and codec).
|
||||
|
||||
Returns:
|
||||
A format name that exists in FORMAT_INFO.
|
||||
"""
|
||||
if user_format:
|
||||
# Map codec names to their correct containers
|
||||
# e.g., 'opus' -> 'ogg', 'aac' -> 'mp4', etc.
|
||||
if user_format in CODEC_TO_CONTAINER_MAP:
|
||||
return CODEC_TO_CONTAINER_MAP[user_format]
|
||||
return user_format
|
||||
|
||||
# If input_file is provided, try to detect container and codec
|
||||
if input_file:
|
||||
try:
|
||||
from .ffmpeg import get_container_format, get_audio_codec
|
||||
container = get_container_format(input_file)
|
||||
codec = get_audio_codec(input_file)
|
||||
fmt = determine_default_format(container, codec)
|
||||
if fmt in FORMAT_INFO:
|
||||
audio_codec = get_audio_codec(input_file)
|
||||
fmt = determine_default_format(container, audio_codec)
|
||||
if fmt in CONTAINER_NAMES:
|
||||
return fmt
|
||||
except Exception:
|
||||
# If detection fails, fall through to legacy logic
|
||||
pass
|
||||
|
||||
# Fallback: legacy behavior
|
||||
# Fallback
|
||||
if stream_info.get('has_video') or stream_info.get('has_subtitle'):
|
||||
return 'matroska'
|
||||
return 'mkv'
|
||||
audio_codec = stream_info.get('audio_codec', '')
|
||||
if audio_codec == 'mp3':
|
||||
return 'mp3'
|
||||
else:
|
||||
return 'mp4' # .m4a
|
||||
return 'mp4'
|
||||
|
||||
|
||||
def validate_format_compatibility(format_name: str, stream_info: Dict,
|
||||
drop_video: bool, drop_subs: bool) -> None:
|
||||
def validate_format_compatibility(
|
||||
container: str,
|
||||
stream_info: Dict,
|
||||
drop_video: bool,
|
||||
drop_subs: bool,
|
||||
input_file: Optional[str] = None,
|
||||
audio_codec: Optional[str] = None,
|
||||
video_codec: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Ensure the chosen container can accommodate the streams we intend to keep.
|
||||
Ensure the chosen container and codec combination is valid.
|
||||
|
||||
Raises:
|
||||
ValueError: If the format is incompatible with the intended streams.
|
||||
ValueError: If the combination is incompatible.
|
||||
"""
|
||||
info = FORMAT_INFO.get(format_name)
|
||||
info = next((c for c in CONTAINER_INFO if c['name'] == container), None)
|
||||
if not info:
|
||||
print(f"Warning: Unknown format '{format_name}'. Proceeding, but may fail.")
|
||||
print(f"Warning: Unknown container '{container}'. Proceeding, but may fail.")
|
||||
return
|
||||
|
||||
if info['audio_only']:
|
||||
if stream_info.get('has_video') and not drop_video:
|
||||
# Check if container is audio-only and video is present (unless dropped)
|
||||
if not info['supports_video'] and stream_info.get('has_video') and not drop_video:
|
||||
raise ValueError(
|
||||
f"Container '{container}' does not support video streams. "
|
||||
"Please use --drop-video or choose a container that supports video (e.g., MKV, MP4)."
|
||||
)
|
||||
|
||||
# Determine actual audio codec for validation
|
||||
# When 'copy' is used, we must still validate the input codec against the container
|
||||
actual_audio_codec = audio_codec if audio_codec and audio_codec != 'copy' else stream_info.get('audio_codec')
|
||||
if actual_audio_codec:
|
||||
if not is_audio_codec_supported(container, actual_audio_codec):
|
||||
raise ValueError(
|
||||
f"Format '{format_name}' does not support video streams. "
|
||||
"Please use --drop-video or choose a container that supports video."
|
||||
)
|
||||
if stream_info.get('has_subtitle') and not drop_subs:
|
||||
raise ValueError(
|
||||
f"Format '{format_name}' does not support subtitle streams. "
|
||||
"Please use --drop-subs or choose a container that supports subtitles."
|
||||
f"Container '{container}' does not support audio codec '{actual_audio_codec}'. "
|
||||
f"Please choose a different container or audio codec."
|
||||
)
|
||||
|
||||
# Check if video codec is supported (if video is present and not dropped)
|
||||
if stream_info.get('has_video') and not drop_video:
|
||||
# Determine the video codec from input file if not provided
|
||||
if video_codec is None and input_file:
|
||||
from .ffmpeg import get_video_codec
|
||||
video_codec = get_video_codec(input_file)
|
||||
# When 'copy' is used, validate the input video codec against the container
|
||||
if video_codec == 'copy' and input_file:
|
||||
from .ffmpeg import get_video_codec
|
||||
video_codec = get_video_codec(input_file)
|
||||
if video_codec:
|
||||
if not is_video_codec_supported(container, video_codec):
|
||||
raise ValueError(
|
||||
f"Container '{container}' does not support video codec '{video_codec}'. "
|
||||
f"Please choose a different container, drop video, or transcode video to a supported codec."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# audio_splitter/handlers.py
|
||||
"""Handler system for cover image attachment.
|
||||
|
||||
This module provides a registry of handlers for different
|
||||
codec + image combinations. Each handler is responsible for
|
||||
attaching the cover image to the output file in the appropriate way.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
def _run_opustags(input_file: str, cover_image_path: str, output_path: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Run opustags to attach a cover image to an Opus file.
|
||||
|
||||
Args:
|
||||
input_file: Path to the input Opus file.
|
||||
cover_image_path: Path to the cover image.
|
||||
output_path: Optional output path (if None, overwrites input).
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
"""
|
||||
cmd = ['opustags', '--set-cover', cover_image_path, input_file]
|
||||
|
||||
if output_path:
|
||||
cmd.extend(['-o', output_path, '-y'])
|
||||
else:
|
||||
cmd.extend(['-i'])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
print(f"opustags failed: {result.stderr}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _handler_default(
|
||||
input_file: str,
|
||||
cover_image_path: str,
|
||||
output_path: str,
|
||||
stream_info: Dict,
|
||||
audio_codec: str,
|
||||
video_codec: str,
|
||||
video_quality: Optional[int],
|
||||
drop_video: bool,
|
||||
drop_subs: bool,
|
||||
) -> bool:
|
||||
"""
|
||||
Default handler: use ffmpeg with -disposition attached_pic.
|
||||
|
||||
This is the standard approach for formats like MP3, FLAC, etc.
|
||||
"""
|
||||
# This handler doesn't do anything - the cover is already handled
|
||||
# by the ffmpeg command in core.py via build_ffmpeg_command()
|
||||
# We just return True to indicate success.
|
||||
return True
|
||||
|
||||
|
||||
def _handler_opus_with_cover(
|
||||
input_file: str,
|
||||
cover_image_path: str,
|
||||
output_path: str,
|
||||
stream_info: Dict,
|
||||
audio_codec: str,
|
||||
video_codec: str,
|
||||
video_quality: Optional[int],
|
||||
drop_video: bool,
|
||||
drop_subs: bool,
|
||||
) -> bool:
|
||||
"""
|
||||
Handler for Opus files with cover image.
|
||||
|
||||
Uses opustags to attach the cover image after the file is processed.
|
||||
"""
|
||||
return _run_opustags(output_path, cover_image_path)
|
||||
|
||||
|
||||
def _handler_mp3_with_cover(
|
||||
input_file: str,
|
||||
cover_image_path: str,
|
||||
output_path: str,
|
||||
stream_info: Dict,
|
||||
audio_codec: str,
|
||||
video_codec: str,
|
||||
video_quality: Optional[int],
|
||||
drop_video: bool,
|
||||
drop_subs: bool,
|
||||
) -> bool:
|
||||
"""
|
||||
Handler for MP3 files with cover image.
|
||||
|
||||
Uses ffmpeg with -disposition attached_pic.
|
||||
"""
|
||||
# MP3 handler - the cover is already attached by ffmpeg in core.py
|
||||
return True
|
||||
|
||||
|
||||
def _handler_flac_with_cover(
|
||||
input_file: str,
|
||||
cover_image_path: str,
|
||||
output_path: str,
|
||||
stream_info: Dict,
|
||||
audio_codec: str,
|
||||
video_codec: str,
|
||||
video_quality: Optional[int],
|
||||
drop_video: bool,
|
||||
drop_subs: bool,
|
||||
) -> bool:
|
||||
"""
|
||||
Handler for FLAC files with cover image.
|
||||
|
||||
Uses ffmpeg with -disposition attached_pic.
|
||||
"""
|
||||
# FLAC handler - the cover is already attached by ffmpeg in core.py
|
||||
return True
|
||||
|
||||
|
||||
# Handler registry: maps (output_container, input_codec) to handler function
|
||||
HANDLER_REGISTRY: Dict[tuple, callable] = {
|
||||
('opus', 'opus'): _handler_opus_with_cover,
|
||||
('ogg', 'opus'): _handler_opus_with_cover,
|
||||
('mp3', 'mp3'): _handler_mp3_with_cover,
|
||||
('flac', 'flac'): _handler_flac_with_cover,
|
||||
}
|
||||
|
||||
|
||||
def get_handler(output_container: str, input_audio_codec: Optional[str] = None):
|
||||
"""
|
||||
Get the appropriate handler for the given container and input codec.
|
||||
|
||||
Args:
|
||||
output_container: Output container format (e.g., 'opus', 'mp3', 'flac').
|
||||
input_audio_codec: Input audio codec (e.g., 'opus', 'mp3', 'flac').
|
||||
|
||||
Returns:
|
||||
Handler function, or the default handler if no specific handler is found.
|
||||
"""
|
||||
if input_audio_codec:
|
||||
key = (output_container, input_audio_codec)
|
||||
if key in HANDLER_REGISTRY:
|
||||
return HANDLER_REGISTRY[key]
|
||||
|
||||
# Fall back to container-only key
|
||||
if (output_container, None) in HANDLER_REGISTRY:
|
||||
return HANDLER_REGISTRY[(output_container, None)]
|
||||
|
||||
# Default handler
|
||||
return _handler_default
|
||||
|
||||
|
||||
def needs_drop_video(output_container: str, input_audio_codec: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Check if the handler requires dropping video even if user didn't specify --drop-video.
|
||||
|
||||
Args:
|
||||
output_container: Output container format.
|
||||
input_audio_codec: Input audio codec.
|
||||
|
||||
Returns:
|
||||
True if video should be dropped, False otherwise.
|
||||
"""
|
||||
handler = get_handler(output_container, input_audio_codec)
|
||||
# The opus handler requires dropping video
|
||||
return handler == _handler_opus_with_cover
|
||||
+177
-53
@@ -1,11 +1,18 @@
|
||||
"""Command‑line interface and entry point."""
|
||||
#!/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 argparse
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import argparse
|
||||
|
||||
from .constants import DEFAULT_BAD_CHARS
|
||||
from .defaults import (
|
||||
DEFAULT_FORMAT,
|
||||
DEFAULT_OUTPUT_TEMPLATE,
|
||||
@@ -24,50 +31,164 @@ from .defaults import (
|
||||
DEFAULT_REPLACE_BAD_CHARS,
|
||||
DEFAULT_SKIP_EXISTING,
|
||||
DEFAULT_DELETE_ORIGINAL,
|
||||
DEFAULT_TRANSCODE_TO,
|
||||
)
|
||||
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 audio file using a tracklist.")
|
||||
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')
|
||||
|
||||
# Output options
|
||||
parser.add_argument('--format', default=DEFAULT_FORMAT, help=f"Output container format (default: {DEFAULT_FORMAT})")
|
||||
parser.add_argument('--transcode-to', default=DEFAULT_TRANSCODE_TO, help="Audio codec to transcode to (default: copy)")
|
||||
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")
|
||||
# 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=f"Output filename template (default: {DEFAULT_OUTPUT_TEMPLATE})")
|
||||
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=f"Replacement character (default: {DEFAULT_REPLACEMENT_CHAR})")
|
||||
parser.add_argument('--bad-chars', default=DEFAULT_BAD_CHARS, help=f"Bad characters to replace (default: {DEFAULT_BAD_CHARS})")
|
||||
parser.add_argument('--skip-existing', action='store_true', default=DEFAULT_SKIP_EXISTING, help="Skip existing output files")
|
||||
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 options
|
||||
# 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=f"Separator for merged comments (default: {DEFAULT_COMMENT_SEPARATOR})")
|
||||
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=f"Tracklist format (default: {DEFAULT_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(
|
||||
'--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()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Input validation
|
||||
# --------------------------------------------------------------------------
|
||||
# Validate input
|
||||
if not os.path.exists(args.input_file):
|
||||
print(f"Error: Input file not found: {args.input_file}")
|
||||
sys.exit(1)
|
||||
@@ -76,12 +197,13 @@ def main():
|
||||
print(f"Error: Tracklist file not found: {args.tracklist_file}")
|
||||
sys.exit(1)
|
||||
|
||||
# Ensure the replacement character is a single character.
|
||||
if len(args.replacement_char) != 1:
|
||||
print("Error: --replacement-char must be a single character.")
|
||||
# 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 that FFmpeg is installed.
|
||||
# Check FFmpeg
|
||||
try:
|
||||
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
@@ -89,14 +211,14 @@ def main():
|
||||
print(" - https://ffmpeg.org/download.html")
|
||||
sys.exit(1)
|
||||
|
||||
# Parse the tracklist using the user‑provided format.
|
||||
# Parse tracklist format
|
||||
try:
|
||||
tokens = parse_format(args.tracklist_format)
|
||||
except ValueError as error:
|
||||
print(f"Error in --tracklist-format: {error}")
|
||||
except ValueError as e:
|
||||
print(f"Error in --tracklist-format: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Read and parse the tracklist file.
|
||||
# Read tracklist
|
||||
tracks = read_tracklist(args.tracklist_file, tokens)
|
||||
if not tracks:
|
||||
print("Error: No valid tracks found in tracklist file.")
|
||||
@@ -104,7 +226,21 @@ def main():
|
||||
|
||||
print(f"Found {len(tracks)} tracks.")
|
||||
|
||||
# Dry‑run mode: display parsed data and exit.
|
||||
# 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)
|
||||
@@ -118,34 +254,22 @@ def main():
|
||||
print(f"{idx:3d} | " + " | ".join(values))
|
||||
print("-" * 60)
|
||||
print("Dry‑run complete. No files were created.")
|
||||
sys.exit(0)
|
||||
return
|
||||
|
||||
# Determine the output directory.
|
||||
# Determine output directory
|
||||
if args.output_dir:
|
||||
output_dir = args.output_dir
|
||||
print(f"Using custom output directory: {output_dir}")
|
||||
else:
|
||||
base_name = os.path.splitext(os.path.basename(args.input_file))[0]
|
||||
output_dir = base_name + "_splits"
|
||||
print(f"Using default output directory: {output_dir}")
|
||||
|
||||
# Run the splitter.
|
||||
# Run split
|
||||
try:
|
||||
split_audio(args.input_file, output_dir, tracks, args)
|
||||
except (RuntimeError, ValueError) as error:
|
||||
print(f"Error: {error}")
|
||||
except Exception as e:
|
||||
print(f"Error during split: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Delete original file if requested and successful.
|
||||
# --------------------------------------------------------------------------
|
||||
if args.delete_original:
|
||||
try:
|
||||
os.remove(args.input_file)
|
||||
print(f"Deleted original file: {args.input_file}")
|
||||
except OSError as e:
|
||||
print(f"Warning: Could not delete original file: {e}")
|
||||
|
||||
print("Done!")
|
||||
|
||||
|
||||
|
||||
@@ -73,4 +73,15 @@ def resolve_end_times(track_times, total_duration):
|
||||
if end_sec <= start_sec:
|
||||
raise ValueError(f"Track {idx+1}: end time ({end_sec}) is not after start ({start_sec})")
|
||||
resolved.append((start_sec, end_sec))
|
||||
|
||||
# Warn about overlapping intervals
|
||||
for idx in range(len(resolved) - 1):
|
||||
cur_end = resolved[idx][1]
|
||||
next_start = resolved[idx + 1][0]
|
||||
if cur_end > next_start:
|
||||
print(
|
||||
f"Warning: Track {idx+1} ends at {cur_end}s but Track {idx+2} starts at "
|
||||
f"{next_start}s — timestamps overlap by {cur_end - next_start:.1f}s."
|
||||
)
|
||||
|
||||
return resolved
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Install FFmpeg, system dependencies, and gosu from APT
|
||||
# Install FFmpeg, opustags, system dependencies, and gosu from APT
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
opustags \
|
||||
ca-certificates \
|
||||
gosu \
|
||||
&& \
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""API route handlers."""
|
||||
|
||||
from . import upload, split, split_file, status, download, formats, info
|
||||
from . import upload, split, split_file, status, download, websocket, formats, info
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# web/backend/api/formats.py
|
||||
"""Endpoint to expose format information to the frontend."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.constants import FORMAT_INFO
|
||||
from audio_splitter.constants import CONTAINER_INFO, CODEC_INFO, COMPATIBILITY_MATRIX
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["formats"])
|
||||
|
||||
@@ -10,16 +11,10 @@ router = APIRouter(prefix="/api", tags=["formats"])
|
||||
@router.get("/formats")
|
||||
async def get_formats():
|
||||
"""
|
||||
Return the list of supported container formats with their properties.
|
||||
Return the list of supported containers, codecs, and compatibility matrix.
|
||||
"""
|
||||
return {
|
||||
"formats": [
|
||||
{
|
||||
"name": name,
|
||||
"ffmpeg": info["ffmpeg"],
|
||||
"extension": info["ext"],
|
||||
"audio_only": info["audio_only"],
|
||||
}
|
||||
for name, info in FORMAT_INFO.items()
|
||||
]
|
||||
"containers": CONTAINER_INFO,
|
||||
"codecs": CODEC_INFO,
|
||||
"compatibility": COMPATIBILITY_MATRIX,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from backend.services.task_manager import task_manager
|
||||
# Import core functions
|
||||
from audio_splitter.ffmpeg import get_stream_info, get_container_format, get_audio_codec
|
||||
from audio_splitter.formats import determine_default_format
|
||||
from audio_splitter.constants import FORMAT_INFO
|
||||
from audio_splitter.constants import CONTAINER_NAMES
|
||||
from audio_splitter.defaults import DEFAULT_FORMAT
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["info"])
|
||||
@@ -63,7 +63,7 @@ async def get_recommended_format(task_id: str):
|
||||
# Fallback if detection fails or format is unsupported
|
||||
if fmt is None:
|
||||
fmt = DEFAULT_FORMAT
|
||||
if fmt not in FORMAT_INFO:
|
||||
if fmt not in CONTAINER_NAMES:
|
||||
fmt = "mp3" # ultimate fallback
|
||||
|
||||
return {"format": fmt}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Split task endpoint."""
|
||||
"""Split task endpoint (JSON tracklist)."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
|
||||
@@ -24,6 +24,29 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
|
||||
detail=f"Task {task_id} is already {status['status']}"
|
||||
)
|
||||
|
||||
# Build options dict from request
|
||||
options = {
|
||||
"container": request.container,
|
||||
"audio_codec": request.audio_codec,
|
||||
"video_codec": request.video_codec,
|
||||
"subtitle_codec": request.subtitle_codec,
|
||||
"video_quality": request.video_quality,
|
||||
"drop_video": request.drop_video,
|
||||
"drop_subs": request.drop_subs,
|
||||
"number_tracks": request.number_tracks,
|
||||
"replace_bad_chars": request.replace_bad_chars,
|
||||
"replacement_char": request.replacement_char,
|
||||
"bad_chars": request.bad_chars,
|
||||
"skip_existing": request.skip_existing,
|
||||
"output_template": request.output_template,
|
||||
"album": request.album,
|
||||
"comment": request.comment,
|
||||
"no_comment": request.no_comment,
|
||||
"comment_stream": request.comment_stream,
|
||||
"merge_comments": request.merge_comments,
|
||||
"comment_separator": request.comment_separator,
|
||||
}
|
||||
|
||||
task_manager.update_task(
|
||||
task_id,
|
||||
status=TaskStatus.PROCESSING,
|
||||
@@ -31,9 +54,14 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
|
||||
message="Preparing to split..."
|
||||
)
|
||||
|
||||
background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options)
|
||||
background_tasks.add_task(
|
||||
run_split_task,
|
||||
task_id,
|
||||
request.tracklist,
|
||||
options
|
||||
)
|
||||
|
||||
return SplitResponse(
|
||||
task_id=task_id,
|
||||
status=TaskStatus.PROCESSING
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks
|
||||
|
||||
@@ -13,7 +14,6 @@ from backend.models.response import SplitResponse, TaskStatus
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["split"])
|
||||
|
||||
# Import CLI tracklist parsing functions
|
||||
from audio_splitter.tracklist import parse_format, read_tracklist
|
||||
|
||||
|
||||
@@ -22,29 +22,33 @@ async def split_from_file(
|
||||
background_tasks: BackgroundTasks,
|
||||
task_id: str = Form(...),
|
||||
tracklist_file: UploadFile = File(...),
|
||||
options: str = Form("{}"),
|
||||
# New fields
|
||||
container: Optional[str] = Form(None),
|
||||
audio_codec: str = Form("copy"),
|
||||
video_codec: str = Form("copy"),
|
||||
subtitle_codec: str = Form("copy"),
|
||||
video_quality: Optional[int] = Form(None),
|
||||
drop_video: bool = Form(False),
|
||||
drop_subs: bool = Form(False),
|
||||
number_tracks: bool = Form(False),
|
||||
replace_bad_chars: bool = Form(False),
|
||||
replacement_char: str = Form("_"),
|
||||
bad_chars: str = Form(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
|
||||
skip_existing: bool = Form(False),
|
||||
output_template: str = Form("%an-%tn.%ext"),
|
||||
album: Optional[str] = Form(None),
|
||||
comment: Optional[str] = Form(None),
|
||||
no_comment: bool = Form(False),
|
||||
comment_stream: Optional[int] = Form(None),
|
||||
merge_comments: bool = Form(False),
|
||||
comment_separator: str = Form("; "),
|
||||
options: str = Form("{}"), # backward-compatible, but we now use explicit fields
|
||||
tracklist_format: str = Form("%ts %tn - %an"),
|
||||
):
|
||||
"""
|
||||
Start a splitting task using a tracklist file.
|
||||
|
||||
This endpoint accepts a plain text tracklist file (like the CLI does)
|
||||
and parses it using the same logic.
|
||||
|
||||
Args:
|
||||
task_id: Task ID from upload.
|
||||
tracklist_file: Tracklist file (text/plain).
|
||||
options: JSON string of all CLI options.
|
||||
tracklist_format: Format string for parsing (default: "%ts %tn - %an").
|
||||
|
||||
Returns:
|
||||
SplitResponse with task_id and status.
|
||||
"""
|
||||
# Validate task exists
|
||||
if not task_manager.has_task(task_id):
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
# Check if task is already processing
|
||||
status = task_manager.get_status(task_id)
|
||||
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
|
||||
raise HTTPException(
|
||||
@@ -52,14 +56,12 @@ async def split_from_file(
|
||||
detail=f"Task {task_id} is already {status['status']}"
|
||||
)
|
||||
|
||||
# Validate file type
|
||||
if not tracklist_file.filename.endswith(('.txt', '.text')):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Tracklist file must be a text file (.txt or .text)"
|
||||
)
|
||||
|
||||
# Read and parse the tracklist file
|
||||
try:
|
||||
content = await tracklist_file.read()
|
||||
text = content.decode('utf-8')
|
||||
@@ -75,17 +77,13 @@ async def split_from_file(
|
||||
detail="Tracklist file is empty"
|
||||
)
|
||||
|
||||
# Parse the tracklist using CLI logic
|
||||
# Parse tracklist using CLI logic
|
||||
try:
|
||||
# Parse the format string into tokens
|
||||
tokens = parse_format(tracklist_format)
|
||||
|
||||
# Write the content to a temporary file (read_tracklist expects a file path)
|
||||
temp_tracklist_path = settings.temp_dir / task_id / "tracklist.txt"
|
||||
temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_tracklist_path.write_text(text, encoding='utf-8')
|
||||
|
||||
# Parse the tracklist file using CLI logic
|
||||
tracklist_dicts = read_tracklist(str(temp_tracklist_path), tokens)
|
||||
|
||||
except Exception as e:
|
||||
@@ -100,7 +98,7 @@ async def split_from_file(
|
||||
detail="No valid tracks found in tracklist file"
|
||||
)
|
||||
|
||||
# Convert dicts to TracklistEntry objects (same as JSON endpoint)
|
||||
# Convert dicts to TracklistEntry objects
|
||||
try:
|
||||
tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts]
|
||||
except Exception as e:
|
||||
@@ -109,14 +107,28 @@ async def split_from_file(
|
||||
detail=f"Invalid tracklist data: {str(e)}"
|
||||
)
|
||||
|
||||
# Parse options JSON
|
||||
try:
|
||||
options_dict = json.loads(options)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid JSON in options field"
|
||||
)
|
||||
# Build options dict from explicit fields (ignore `options` parameter)
|
||||
options = {
|
||||
"container": container,
|
||||
"audio_codec": audio_codec,
|
||||
"video_codec": video_codec,
|
||||
"subtitle_codec": subtitle_codec,
|
||||
"video_quality": video_quality,
|
||||
"drop_video": drop_video,
|
||||
"drop_subs": drop_subs,
|
||||
"number_tracks": number_tracks,
|
||||
"replace_bad_chars": replace_bad_chars,
|
||||
"replacement_char": replacement_char,
|
||||
"bad_chars": bad_chars,
|
||||
"skip_existing": skip_existing,
|
||||
"output_template": output_template,
|
||||
"album": album,
|
||||
"comment": comment,
|
||||
"no_comment": no_comment,
|
||||
"comment_stream": comment_stream,
|
||||
"merge_comments": merge_comments,
|
||||
"comment_separator": comment_separator,
|
||||
}
|
||||
|
||||
# Update task status
|
||||
task_manager.update_task(
|
||||
@@ -126,12 +138,11 @@ async def split_from_file(
|
||||
message="Preparing to split..."
|
||||
)
|
||||
|
||||
# Start background task
|
||||
background_tasks.add_task(
|
||||
run_split_task,
|
||||
task_id,
|
||||
tracklist_entries, # now TracklistEntry objects
|
||||
options_dict
|
||||
tracklist_entries,
|
||||
options
|
||||
)
|
||||
|
||||
return SplitResponse(
|
||||
|
||||
@@ -14,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["upload"])
|
||||
|
||||
ALLOWED_EXTENSIONS = {
|
||||
".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus",
|
||||
".aac", ".wma", ".aiff", ".alac", ".ac3"
|
||||
".aac", ".wma", ".aiff", ".alac", ".ac3",
|
||||
".mp4", ".mkv", ".webm",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
FORMAT_INFO = {
|
||||
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
|
||||
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
|
||||
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
|
||||
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
|
||||
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
|
||||
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
|
||||
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
|
||||
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
|
||||
}
|
||||
@@ -17,19 +17,16 @@ class TracklistEntry(BaseModel):
|
||||
|
||||
@validator("ts")
|
||||
def validate_timestamp(cls, v: str) -> str:
|
||||
"""Basic timestamp validation (format and range)."""
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Timestamp cannot be empty")
|
||||
|
||||
# Check for range format (start-end)
|
||||
if "-" in v:
|
||||
parts = v.split("-", 1)
|
||||
start = parts[0].strip()
|
||||
end = parts[1].strip()
|
||||
if not start or not end:
|
||||
raise ValueError("Invalid range format. Expected 'start-end'")
|
||||
# Validate each part with the same logic
|
||||
for ts in [start, end]:
|
||||
cls._validate_single_timestamp(ts)
|
||||
else:
|
||||
@@ -39,7 +36,6 @@ class TracklistEntry(BaseModel):
|
||||
|
||||
@staticmethod
|
||||
def _validate_single_timestamp(ts: str) -> None:
|
||||
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
|
||||
parts = ts.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS")
|
||||
@@ -55,4 +51,30 @@ class SplitRequest(BaseModel):
|
||||
|
||||
task_id: str = Field(..., description="Task ID from upload")
|
||||
tracklist: List[TracklistEntry] = Field(..., description="List of tracks")
|
||||
options: dict = Field(default_factory=dict, description="All CLI options")
|
||||
|
||||
# Container and codec options
|
||||
container: Optional[str] = Field(None, description="Output container (auto-detect if None)")
|
||||
audio_codec: Optional[str] = Field("copy", description="Audio codec (copy or encoder name)")
|
||||
video_codec: Optional[str] = Field("copy", description="Video codec (copy or encoder name)")
|
||||
subtitle_codec: Optional[str] = Field("copy", description="Subtitle codec (copy or encoder name)")
|
||||
video_quality: Optional[int] = Field(None, description="Video quality (encoder-specific integer)")
|
||||
|
||||
# Stream handling
|
||||
drop_video: bool = Field(False, description="Remove video streams")
|
||||
drop_subs: bool = Field(False, description="Remove subtitle streams")
|
||||
|
||||
# Filename options
|
||||
number_tracks: bool = Field(False, description="Prepend track numbers")
|
||||
replace_bad_chars: bool = Field(False, description="Replace bad characters")
|
||||
replacement_char: str = Field("_", description="Replacement character")
|
||||
bad_chars: str = Field(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ', description="Bad characters to replace")
|
||||
skip_existing: bool = Field(False, description="Skip existing files")
|
||||
output_template: str = Field("%an-%tn.%ext", description="Output filename template")
|
||||
|
||||
# Metadata options
|
||||
album: Optional[str] = Field(None, description="Album name")
|
||||
comment: Optional[str] = Field(None, description="Comment")
|
||||
no_comment: bool = Field(False, description="Ignore comment")
|
||||
comment_stream: Optional[int] = Field(None, description="Comment stream index")
|
||||
merge_comments: bool = Field(False, description="Merge all comments")
|
||||
comment_separator: str = Field("; ", description="Separator for merged comments")
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Business logic services."""
|
||||
"""Business logic services."""
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Dict, List, Any
|
||||
from typing import List, Dict, Any
|
||||
import time
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.task_manager import task_manager
|
||||
@@ -13,31 +13,12 @@ from backend.services.file_manager import FileManager
|
||||
from backend.models.request import TracklistEntry
|
||||
from backend.models.response import TaskStatus
|
||||
|
||||
# Import central defaults
|
||||
from audio_splitter.defaults import (
|
||||
DEFAULT_FORMAT,
|
||||
DEFAULT_OUTPUT_TEMPLATE,
|
||||
DEFAULT_REPLACEMENT_CHAR,
|
||||
DEFAULT_BAD_CHARS,
|
||||
DEFAULT_ALBUM,
|
||||
DEFAULT_COMMENT,
|
||||
DEFAULT_NO_COMMENT,
|
||||
DEFAULT_COMMENT_STREAM,
|
||||
DEFAULT_MERGE_COMMENTS,
|
||||
DEFAULT_COMMENT_SEPARATOR,
|
||||
DEFAULT_DROP_VIDEO,
|
||||
DEFAULT_DROP_SUBS,
|
||||
DEFAULT_NUMBER_TRACKS,
|
||||
DEFAULT_REPLACE_BAD_CHARS,
|
||||
DEFAULT_SKIP_EXISTING,
|
||||
DEFAULT_DELETE_ORIGINAL,
|
||||
DEFAULT_TRANSCODE_TO,
|
||||
)
|
||||
|
||||
|
||||
def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None:
|
||||
try:
|
||||
task_manager.update_task_with_progress(task_id, progress=5, message="Initializing...")
|
||||
task_manager.update_task_with_progress(
|
||||
task_id, progress=5, message="Initializing..."
|
||||
)
|
||||
|
||||
input_path = FileManager.get_input_path(task_id)
|
||||
if not input_path or not input_path.exists():
|
||||
@@ -45,7 +26,6 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
||||
|
||||
output_dir = FileManager.ensure_output_dir(task_id)
|
||||
|
||||
# Convert tracklist to dicts (CLI format)
|
||||
tracks = []
|
||||
for entry in tracklist:
|
||||
track_dict = {
|
||||
@@ -58,25 +38,30 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
||||
}
|
||||
tracks.append(track_dict)
|
||||
|
||||
# Build args namespace using defaults where options not provided
|
||||
from types import SimpleNamespace
|
||||
|
||||
# Build args namespace using the new fields
|
||||
args = SimpleNamespace(
|
||||
format=options.get("format", DEFAULT_FORMAT),
|
||||
transcode_to=options.get("transcode_to", DEFAULT_TRANSCODE_TO),
|
||||
drop_video=options.get("drop_video", DEFAULT_DROP_VIDEO),
|
||||
drop_subs=options.get("drop_subs", DEFAULT_DROP_SUBS),
|
||||
number_tracks=options.get("number_tracks", DEFAULT_NUMBER_TRACKS),
|
||||
replace_bad_chars=options.get("replace_bad_chars", DEFAULT_REPLACE_BAD_CHARS),
|
||||
replacement_char=options.get("replacement_char", DEFAULT_REPLACEMENT_CHAR),
|
||||
bad_chars=options.get("bad_chars", DEFAULT_BAD_CHARS),
|
||||
skip_existing=options.get("skip_existing", DEFAULT_SKIP_EXISTING),
|
||||
output_template=options.get("output_template", DEFAULT_OUTPUT_TEMPLATE),
|
||||
album=options.get("album", DEFAULT_ALBUM),
|
||||
comment=options.get("comment", DEFAULT_COMMENT),
|
||||
no_comment=options.get("no_comment", DEFAULT_NO_COMMENT),
|
||||
comment_stream=options.get("comment_stream", DEFAULT_COMMENT_STREAM),
|
||||
merge_comments=options.get("merge_comments", DEFAULT_MERGE_COMMENTS),
|
||||
comment_separator=options.get("comment_separator", DEFAULT_COMMENT_SEPARATOR),
|
||||
delete_original=DEFAULT_DELETE_ORIGINAL, # never delete in web
|
||||
container=options.get("container"), # None -> auto-detect
|
||||
audio_codec=options.get("audio_codec", "copy"),
|
||||
video_codec=options.get("video_codec", "copy"),
|
||||
subtitle_codec=options.get("subtitle_codec", "copy"),
|
||||
video_quality=options.get("video_quality"),
|
||||
drop_video=options.get("drop_video", False),
|
||||
drop_subs=options.get("drop_subs", False),
|
||||
number_tracks=options.get("number_tracks", False),
|
||||
replace_bad_chars=options.get("replace_bad_chars", False),
|
||||
replacement_char=options.get("replacement_char", "_"),
|
||||
bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
|
||||
skip_existing=options.get("skip_existing", False),
|
||||
output_template=options.get("output_template", "%an-%tn.%ext"),
|
||||
album=options.get("album", None),
|
||||
comment=options.get("comment", None),
|
||||
no_comment=options.get("no_comment", False),
|
||||
comment_stream=options.get("comment_stream", None),
|
||||
merge_comments=options.get("merge_comments", False),
|
||||
comment_separator=options.get("comment_separator", "; "),
|
||||
delete_original=False,
|
||||
)
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent.parent
|
||||
@@ -85,7 +70,13 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
||||
|
||||
from audio_splitter.core import split_audio
|
||||
|
||||
task_manager.update_task_with_progress(task_id, progress=10, message="Starting split...")
|
||||
# Cover image handling is now done internally by split_audio()
|
||||
# via the handler system (opustags for Opus, ffmpeg for others)
|
||||
args.cover_image = None
|
||||
|
||||
task_manager.update_task_with_progress(
|
||||
task_id, progress=10, message="Starting split..."
|
||||
)
|
||||
|
||||
split_audio(str(input_path), str(output_dir), tracks, args)
|
||||
|
||||
|
||||
@@ -68,16 +68,15 @@ const App: React.FC = () => {
|
||||
reset()
|
||||
}
|
||||
|
||||
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError
|
||||
const isSplitDisabled =
|
||||
!taskId ||
|
||||
!isValid ||
|
||||
entries.length === 0 ||
|
||||
isProcessing ||
|
||||
!!formatError
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
theme={createTheme({
|
||||
palette: {
|
||||
mode: theme,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<ThemeProvider theme={createTheme({ palette: { mode: theme } })}>
|
||||
<CssBaseline />
|
||||
<Layout>
|
||||
<Grid container spacing={3}>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import axios from 'axios'
|
||||
import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
|
||||
import {
|
||||
TracklistEntry,
|
||||
SplitOptions,
|
||||
TaskStatus,
|
||||
FormatsResponse,
|
||||
UploadResponse,
|
||||
SplitResponse,
|
||||
} from '../types'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -8,16 +15,14 @@ export const api = axios.create({
|
||||
},
|
||||
})
|
||||
|
||||
export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => {
|
||||
export const uploadFile = async (file: File): Promise<UploadResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await api.post('/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -25,12 +30,32 @@ export const startSplit = async (
|
||||
task_id: string,
|
||||
tracklist: TracklistEntry[],
|
||||
options: SplitOptions
|
||||
): Promise<{ task_id: string; status: string }> => {
|
||||
const response = await api.post('/split', {
|
||||
): Promise<SplitResponse> => {
|
||||
// Build request payload (only send fields that are not undefined)
|
||||
const payload: any = {
|
||||
task_id,
|
||||
tracklist,
|
||||
options,
|
||||
})
|
||||
container: options.container, // null means auto-detect
|
||||
audio_codec: options.audio_codec,
|
||||
video_codec: options.video_codec,
|
||||
subtitle_codec: options.subtitle_codec,
|
||||
video_quality: options.video_quality,
|
||||
drop_video: options.drop_video,
|
||||
drop_subs: options.drop_subs,
|
||||
number_tracks: options.number_tracks,
|
||||
replace_bad_chars: options.replace_bad_chars,
|
||||
replacement_char: options.replacement_char,
|
||||
bad_chars: options.bad_chars,
|
||||
skip_existing: options.skip_existing,
|
||||
output_template: options.output_template,
|
||||
album: options.album || null,
|
||||
comment: options.comment || null,
|
||||
no_comment: options.no_comment,
|
||||
comment_stream: options.comment_stream,
|
||||
merge_comments: options.merge_comments,
|
||||
comment_separator: options.comment_separator,
|
||||
}
|
||||
const response = await api.post('/split', payload)
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -47,8 +72,7 @@ export const getDownloadZipUrl = (task_id: string): string => {
|
||||
return `/api/download/${task_id}/splits.zip`
|
||||
}
|
||||
|
||||
// New functions for format validation feature
|
||||
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
|
||||
export const getFormats = async (): Promise<FormatsResponse> => {
|
||||
const response = await api.get('/formats')
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
@@ -16,7 +16,14 @@ import { ExpandMore, ExpandLess } from '@mui/icons-material'
|
||||
import { useOptionsStore } from '../stores/optionsStore'
|
||||
import { useUploadStore } from '../stores/uploadStore'
|
||||
import { useValidationStore } from '../stores/validationStore'
|
||||
import { getFormats } from '../api/client'
|
||||
import { SplitOptions } from '../types'
|
||||
import { useFilterCodecs } from '../hooks/useFilterCodecs'
|
||||
import { useFormatValidation } from '../hooks/useFormatValidation'
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Section component (collapsible)
|
||||
// ------------------------------------------------------------------------------
|
||||
interface SectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
@@ -25,7 +32,6 @@ interface SectionProps {
|
||||
|
||||
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded)
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
@@ -51,54 +57,64 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Main OptionsPanel
|
||||
// ------------------------------------------------------------------------------
|
||||
export const OptionsPanel: React.FC = () => {
|
||||
const { options, setOptions } = useOptionsStore()
|
||||
const {
|
||||
options,
|
||||
setOptions,
|
||||
containers,
|
||||
codecs,
|
||||
compatibility,
|
||||
setContainers,
|
||||
setCodecs,
|
||||
setCompatibility,
|
||||
} = useOptionsStore()
|
||||
|
||||
const { hasVideo } = useUploadStore()
|
||||
const { formatError, setFormatError } = useValidationStore()
|
||||
|
||||
// Audio-only formats from backend constants (hardcoded for now)
|
||||
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
|
||||
|
||||
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFormat = e.target.value
|
||||
setOptions({ format: newFormat })
|
||||
|
||||
// Validate format
|
||||
if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
|
||||
setFormatError(
|
||||
`Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||
)
|
||||
} else {
|
||||
setFormatError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = e.target.checked
|
||||
setOptions({ drop_video: checked })
|
||||
// Re-validate format
|
||||
if (audioOnlyFormats.includes(options.format) && hasVideo && !checked) {
|
||||
setFormatError(
|
||||
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||
)
|
||||
} else {
|
||||
setFormatError(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate when hasVideo changes (e.g., after upload)
|
||||
// Fetch formats on mount
|
||||
useEffect(() => {
|
||||
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
|
||||
const newError = shouldShowError
|
||||
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||
: null
|
||||
|
||||
// Only update if the error state actually changes
|
||||
if (newError !== formatError) {
|
||||
setFormatError(newError)
|
||||
const fetchFormats = async () => {
|
||||
try {
|
||||
const data = await getFormats()
|
||||
setContainers(data.containers || [])
|
||||
setCodecs(data.codecs || [])
|
||||
setCompatibility(data.compatibility || {})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch formats:', err)
|
||||
}
|
||||
}
|
||||
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
|
||||
fetchFormats()
|
||||
}, [setContainers, setCodecs, setCompatibility])
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Filter codec options based on selected container
|
||||
// --------------------------------------------------------------
|
||||
const { filteredAudioCodecs, filteredVideoCodecs } = useFilterCodecs(compatibility, options.container, codecs)
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Validate compatibility
|
||||
// --------------------------------------------------------------
|
||||
useFormatValidation(options, hasVideo, compatibility, containers, setFormatError)
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Handlers
|
||||
// --------------------------------------------------------------
|
||||
const handleChange = (field: keyof SplitOptions, value: any) => {
|
||||
setOptions({ [field]: value })
|
||||
}
|
||||
|
||||
const handleContainerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value === '' ? null : e.target.value
|
||||
handleChange('container', value)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Render
|
||||
// --------------------------------------------------------------
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
@@ -111,60 +127,101 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Output Settings */}
|
||||
{/* ------------------------------------------------------------------------
|
||||
Output Settings
|
||||
------------------------------------------------------------------------ */}
|
||||
<Section title="Output Settings" defaultExpanded>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Container dropdown */}
|
||||
<TextField
|
||||
label="Container"
|
||||
select
|
||||
value={options.format}
|
||||
onChange={handleFormatChange}
|
||||
value={options.container ?? ''}
|
||||
onChange={handleContainerChange}
|
||||
fullWidth
|
||||
size="small"
|
||||
error={!!formatError}
|
||||
helperText={
|
||||
formatError || "Determines the output file extension and container structure."
|
||||
formatError || "Select a container (auto-detect if empty)."
|
||||
}
|
||||
>
|
||||
<MenuItem value="mp3">MP3 (.mp3)</MenuItem>
|
||||
<MenuItem value="m4a">M4A (.m4a)</MenuItem>
|
||||
<MenuItem value="mkv">MKV (.mkv)</MenuItem>
|
||||
<MenuItem value="mp4">MP4 (.mp4)</MenuItem>
|
||||
<MenuItem value="ogg">OGG (.ogg)</MenuItem>
|
||||
<MenuItem value="opus">OPUS (.opus)</MenuItem>
|
||||
<MenuItem value="flac">FLAC (.flac)</MenuItem>
|
||||
<MenuItem value="wav">WAV (.wav)</MenuItem>
|
||||
<MenuItem value="aac">AAC (.aac)</MenuItem>
|
||||
<MenuItem value="">Auto-detect</MenuItem>
|
||||
{containers.map((c) => (
|
||||
<MenuItem key={c.name} value={c.name}>
|
||||
{c.name.toUpperCase()} ({c.extension})
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{/* Transcode option – remains unchanged but clearly labeled */}
|
||||
{/* Audio Codec dropdown */}
|
||||
<TextField
|
||||
label="Audio Codec (Transcode)"
|
||||
label="Audio Codec"
|
||||
select
|
||||
value={options.transcode_to || ''}
|
||||
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
||||
value={options.audio_codec}
|
||||
onChange={(e) => handleChange('audio_codec', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original."
|
||||
helperText="Audio codec (copy = keep original)"
|
||||
>
|
||||
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
||||
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
||||
<MenuItem value="aac">AAC</MenuItem>
|
||||
<MenuItem value="libopus">OPUS</MenuItem>
|
||||
<MenuItem value="copy">Copy (original)</MenuItem>
|
||||
{filteredAudioCodecs.map((c) => (
|
||||
<MenuItem key={c.name} value={c.name}>
|
||||
{c.name.toUpperCase()}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{/* Video Codec dropdown */}
|
||||
<TextField
|
||||
label="Video Codec"
|
||||
select
|
||||
value={options.video_codec}
|
||||
onChange={(e) => handleChange('video_codec', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText="Video codec (copy = keep original)"
|
||||
disabled={!hasVideo || options.drop_video}
|
||||
>
|
||||
<MenuItem value="copy">Copy (original)</MenuItem>
|
||||
{filteredVideoCodecs.map((c) => (
|
||||
<MenuItem key={c.name} value={c.name}>
|
||||
{c.name.toUpperCase()}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{/* Video Quality */}
|
||||
<TextField
|
||||
label="Video Quality"
|
||||
type="number"
|
||||
value={options.video_quality ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? null : parseInt(e.target.value, 10)
|
||||
handleChange('video_quality', val)
|
||||
}}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={!hasVideo || options.drop_video}
|
||||
helperText="Optional quality value (encoder-specific; e.g., 1-31 for libx264, 0-10 for Theora)"
|
||||
InputProps={{
|
||||
inputProps: { min: 0, max: 51, step: 1 },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Drop video (if video present) */}
|
||||
{hasVideo && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.drop_video}
|
||||
onChange={handleDropVideoChange}
|
||||
onChange={(e) => handleChange('drop_video', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Drop video streams"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drop subtitles */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
@@ -177,7 +234,9 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Filename Settings */}
|
||||
{/* ------------------------------------------------------------------------
|
||||
Filename Settings
|
||||
------------------------------------------------------------------------ */}
|
||||
<Section title="Filename Settings">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
@@ -233,7 +292,9 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Metadata Settings */}
|
||||
{/* ------------------------------------------------------------------------
|
||||
Metadata Settings
|
||||
------------------------------------------------------------------------ */}
|
||||
<Section title="Metadata Settings">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
@@ -264,7 +325,7 @@ export const OptionsPanel: React.FC = () => {
|
||||
type="number"
|
||||
value={options.comment_stream ?? ''}
|
||||
onChange={(e) =>
|
||||
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
|
||||
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value, 10))
|
||||
}
|
||||
size="small"
|
||||
disabled={options.no_comment}
|
||||
@@ -289,7 +350,9 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Tracklist Settings */}
|
||||
{/* ------------------------------------------------------------------------
|
||||
Tracklist Settings
|
||||
------------------------------------------------------------------------ */}
|
||||
<Section title="Tracklist Settings" defaultExpanded>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
@@ -304,9 +367,4 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Section>
|
||||
</Paper>
|
||||
)
|
||||
|
||||
// Helper function for option updates
|
||||
function handleChange(field: string, value: any) {
|
||||
setOptions({ [field]: value })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useOptionsStore } from '../stores/optionsStore' // NEW
|
||||
import { uploadFile, getTaskInfo, getRecommendedFormat } from '../api/client' // NEW
|
||||
import { useTaskStore } from '../stores/taskStore'
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
|
||||
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3', '.mp4', '.mkv', '.webm']
|
||||
|
||||
export const UploadZone: React.FC = () => {
|
||||
const {
|
||||
@@ -70,9 +70,10 @@ export const UploadZone: React.FC = () => {
|
||||
|
||||
// Fetch recommended format and update options
|
||||
try {
|
||||
// Inside UploadZone.tsx, after fetching recommended format:
|
||||
const rec = await getRecommendedFormat(taskId)
|
||||
// Update only the format; keep other options (e.g., transcode_to) as defaults
|
||||
setOptions({ format: rec.format })
|
||||
// Update options store with container (not format)
|
||||
setOptions({ container: rec.format })
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch recommended format, using default', err)
|
||||
}
|
||||
@@ -95,7 +96,7 @@ export const UploadZone: React.FC = () => {
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'audio/*': ALLOWED_EXTENSIONS,
|
||||
'media/*': ALLOWED_EXTENSIONS
|
||||
},
|
||||
multiple: false,
|
||||
disabled: isUploading,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMemo } from 'react'
|
||||
import { CodecInfo } from '../types'
|
||||
|
||||
export interface CompatibilityEntry {
|
||||
audio: string[] | null
|
||||
video: string[] | null
|
||||
}
|
||||
|
||||
export function useFilterCodecs(
|
||||
compatibility: Record<string, CompatibilityEntry>,
|
||||
container: string | null,
|
||||
codecs: CodecInfo[]
|
||||
) {
|
||||
const filteredAudioCodecs = useMemo(() => {
|
||||
const entry = compatibility?.[container || '']
|
||||
if (!entry) return codecs.filter(c => c.codec_type === 'audio')
|
||||
const audioList = entry.audio
|
||||
if (audioList === null) return codecs.filter(c => c.codec_type === 'audio')
|
||||
return codecs.filter(c => c.codec_type === 'audio' && audioList.includes(c.name))
|
||||
}, [compatibility, container, codecs])
|
||||
|
||||
const filteredVideoCodecs = useMemo(() => {
|
||||
const entry = compatibility?.[container || '']
|
||||
if (!entry) return codecs.filter(c => c.codec_type === 'video')
|
||||
const videoList = entry.video
|
||||
if (videoList === null) return codecs.filter(c => c.codec_type === 'video')
|
||||
return codecs.filter(c => c.codec_type === 'video' && videoList.includes(c.name))
|
||||
}, [compatibility, container, codecs])
|
||||
|
||||
return { filteredAudioCodecs, filteredVideoCodecs }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react'
|
||||
import { ContainerInfo } from '../types'
|
||||
import { CompatibilityEntry } from './useFilterCodecs'
|
||||
|
||||
export function useFormatValidation(
|
||||
options: {
|
||||
container: string | null
|
||||
audio_codec: string
|
||||
video_codec: string
|
||||
drop_video: boolean
|
||||
},
|
||||
hasVideo: boolean,
|
||||
compatibility: Record<string, CompatibilityEntry>,
|
||||
containers: ContainerInfo[],
|
||||
setFormatError: (error: string | null) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
const selectedContainer = containers.find(c => c.name === options.container)
|
||||
if (!selectedContainer?.supports_video && hasVideo && !options.drop_video) {
|
||||
setFormatError(
|
||||
`Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Check audio codec compatibility
|
||||
if (options.audio_codec && options.audio_codec !== 'copy' && options.container) {
|
||||
const entry = compatibility?.[options.container]
|
||||
if (entry) {
|
||||
const audioList = entry.audio
|
||||
if (audioList !== null && !audioList.includes(options.audio_codec)) {
|
||||
setFormatError(
|
||||
`Audio codec '${options.audio_codec}' is not supported by container '${options.container}'.`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check video codec compatibility
|
||||
if (options.video_codec && options.video_codec !== 'copy' && options.container && hasVideo && !options.drop_video) {
|
||||
const entry = compatibility?.[options.container]
|
||||
if (entry) {
|
||||
const videoList = entry.video
|
||||
if (videoList !== null && !videoList.includes(options.video_codec)) {
|
||||
setFormatError(
|
||||
`Video codec '${options.video_codec}' is not supported by container '${options.container}'.`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setFormatError(null)
|
||||
}, [options.container, options.audio_codec, options.video_codec, options.drop_video, hasVideo, compatibility, containers, setFormatError])
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
// web/frontend/src/stores/optionsStore.ts
|
||||
import { create } from 'zustand'
|
||||
import { SplitOptions } from '../types'
|
||||
import { SplitOptions, ContainerInfo, CodecInfo } from '../types'
|
||||
import {
|
||||
DEFAULT_FORMAT,
|
||||
DEFAULT_OUTPUT_TEMPLATE,
|
||||
@@ -17,13 +16,16 @@ import {
|
||||
DEFAULT_NUMBER_TRACKS,
|
||||
DEFAULT_REPLACE_BAD_CHARS,
|
||||
DEFAULT_SKIP_EXISTING,
|
||||
DEFAULT_TRANSCODE_TO,
|
||||
DEFAULT_TRACKLIST_FORMAT,
|
||||
} from '../constants/generated'
|
||||
|
||||
// We use DEFAULT_FORMAT only as a fallback; container default is null (auto-detect)
|
||||
const DEFAULT_OPTIONS: SplitOptions = {
|
||||
format: DEFAULT_FORMAT,
|
||||
transcode_to: DEFAULT_TRANSCODE_TO ?? '',
|
||||
container: null,
|
||||
audio_codec: 'copy',
|
||||
video_codec: 'copy',
|
||||
subtitle_codec: 'copy',
|
||||
video_quality: null,
|
||||
drop_video: DEFAULT_DROP_VIDEO,
|
||||
drop_subs: DEFAULT_DROP_SUBS,
|
||||
number_tracks: DEFAULT_NUMBER_TRACKS,
|
||||
@@ -43,15 +45,33 @@ const DEFAULT_OPTIONS: SplitOptions = {
|
||||
|
||||
interface OptionsState {
|
||||
options: SplitOptions
|
||||
setOptions: (options: Partial<SplitOptions>) => void
|
||||
containers: ContainerInfo[]
|
||||
codecs: CodecInfo[]
|
||||
compatibility: Record<string, { audio: string[] | null; video: string[] | null }>
|
||||
setOptions: (newOptions: Partial<SplitOptions>) => void
|
||||
setContainers: (containers: ContainerInfo[]) => void
|
||||
setCodecs: (codecs: CodecInfo[]) => void
|
||||
setCompatibility: (compat: Record<string, { audio: string[] | null; video: string[] | null }>) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useOptionsStore = create<OptionsState>((set) => ({
|
||||
options: { ...DEFAULT_OPTIONS },
|
||||
containers: [],
|
||||
codecs: [],
|
||||
compatibility: {},
|
||||
setOptions: (newOptions) =>
|
||||
set((state) => ({
|
||||
options: { ...state.options, ...newOptions },
|
||||
})),
|
||||
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
|
||||
setContainers: (containers) => set({ containers }),
|
||||
setCodecs: (codecs) => set({ codecs }),
|
||||
setCompatibility: (compatibility) => set({ compatibility }),
|
||||
reset: () =>
|
||||
set({
|
||||
options: { ...DEFAULT_OPTIONS },
|
||||
containers: [],
|
||||
codecs: [],
|
||||
compatibility: {},
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -22,8 +22,11 @@ export interface TaskStatus {
|
||||
}
|
||||
|
||||
export interface SplitOptions {
|
||||
format: string
|
||||
transcode_to?: string
|
||||
container: string | null // null = auto-detect
|
||||
audio_codec: string // 'copy' or encoder name
|
||||
video_codec: string // 'copy' or encoder name
|
||||
subtitle_codec: string // 'copy' or encoder name
|
||||
video_quality: number | null // encoder-specific integer
|
||||
drop_video: boolean
|
||||
drop_subs: boolean
|
||||
number_tracks: boolean
|
||||
@@ -38,7 +41,34 @@ export interface SplitOptions {
|
||||
comment_stream: number | null
|
||||
merge_comments: boolean
|
||||
comment_separator: string
|
||||
tracklist_format: string
|
||||
tracklist_format: string // for frontend parsing
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
name: string
|
||||
ffmpeg: string
|
||||
extension: string
|
||||
supports_video: boolean
|
||||
supports_subs: boolean
|
||||
}
|
||||
|
||||
export interface CodecInfo {
|
||||
name: string
|
||||
ffmpeg: string
|
||||
recommended_container: string
|
||||
recommended_extension: string
|
||||
codec_type: 'audio' | 'video' | 'subtitle'
|
||||
}
|
||||
|
||||
export interface CompatibilityEntry {
|
||||
audio: string[] | null
|
||||
video: string[] | null
|
||||
}
|
||||
|
||||
export interface FormatsResponse {
|
||||
containers: ContainerInfo[]
|
||||
codecs: CodecInfo[]
|
||||
compatibility: Record<string, CompatibilityEntry>
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
|
||||
Reference in New Issue
Block a user