8 Commits

18 changed files with 1126 additions and 422 deletions
+131 -16
View File
@@ -1,20 +1,135 @@
"""Global constants and default values.""" # audio_splitter/constants.py
"""Global constants for the audio splitter.
# Mapping from userfriendly format names to FFmpeg format identifiers, This file serves as the single source of truth for:
# file extensions, and whether the container is audioonly. - 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)
"""
# ------------------------------------------------------------------------------
# Container information
# ------------------------------------------------------------------------------
CONTAINER_INFO = [
# Audio-only containers
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
# Containers that support video and subtitles
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'matroska', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'audio_only': False, 'supports_video': True, 'supports_subs': False},
]
# Legacy FORMAT_INFO for backward compatibility
FORMAT_INFO = { FORMAT_INFO = {
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True}, container['name']: {
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True}, 'ffmpeg': container['ffmpeg'],
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False}, 'ext': container['extension'],
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False}, 'audio_only': container['audio_only'],
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False}, }
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True}, for container in CONTAINER_INFO
'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},
} }
# Default characters to replace when --replace-bad-chars is enabled. # ------------------------------------------------------------------------------
# Includes common punctuation, single quote, and a trailing space. # Codec information
DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' ' # ------------------------------------------------------------------------------
CODEC_INFO = [
# Audio codecs
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'supports_transcoding': True, 'supports_video': False},
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'supports_transcoding': True, 'supports_video': False},
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'supports_transcoding': True, 'supports_video': False},
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'supports_transcoding': True, 'supports_video': False},
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
{'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'supports_transcoding': True, 'supports_video': False},
# Video codecs
{'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'supports_transcoding': True, 'supports_video': True},
{'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True},
{'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True},
{'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'supports_transcoding': True, 'supports_video': True},
{'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True},
{'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True},
{'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'supports_transcoding': False, 'supports_video': True},
{'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'supports_transcoding': False, 'supports_video': True},
]
# 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}
# ------------------------------------------------------------------------------
# Video codec support per container (legacy, will be superseded by compatibility matrix)
# ------------------------------------------------------------------------------
CONTAINER_VIDEO_CODEC_SUPPORT = {
'mp4': ['h264', 'h265', 'vp9', 'av1', 'mpeg4', 'hevc'],
'mkv': ['*'],
'matroska': ['*'],
'ogg': ['theora', 'dirac', 'vp8', 'png', 'mjpeg'],
'webm': ['vp8', 'vp9', 'av1'],
'mp3': [],
'm4a': [],
'flac': [],
'wav': [],
'aac': [],
}
# ------------------------------------------------------------------------------
# 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': []},
'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []},
'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']},
'mkv': {'audio': None, 'video': None},
'matroska': {'audio': None, 'video': None},
'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8']},
'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']},
'flac': {'audio': ['flac'], 'video': []},
'wav': {'audio': ['pcm_s16le'], 'video': []},
'aac': {'audio': ['aac'], 'video': []},
}
# ------------------------------------------------------------------------------
# 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'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
+146 -29
View File
@@ -1,30 +1,68 @@
"""Core logic: orchestrates the splitting process.""" # audio_splitter/core.py
"""Core splitting logic orchestrates the entire split process."""
import os import os
import subprocess import subprocess
import sys
from typing import List, Dict, Any
from .constants import FORMAT_INFO from .constants import (
from .ffmpeg import get_audio_duration, get_stream_info, get_metadata, build_ffmpeg_command FORMAT_INFO,
from .formats import determine_output_format, validate_format_compatibility CODEC_TO_EXTENSION_MAP,
CODEC_NAME_TO_FFMPEG, # <-- Add this
)
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 .timestamp import parse_track_timestamps, resolve_end_times from .timestamp import parse_track_timestamps, resolve_end_times
from .filename import build_filename from .filename import build_filename
from .metadata import build_metadata_dict from .metadata import build_metadata_dict
from .utils import format_time 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. Main orchestration function: split the audio file into tracks.
Args: Args:
input_file: Path to the input media file. input_file: Path to the input media file.
output_directory: Directory where output files will be saved. output_directory: Directory where output files will be saved.
tracks: List of dicts, each containing parsed fields. tracks: List of dicts, each containing parsed fields (ts, tn, an, ...).
args: Parsed commandline arguments (namespace). args: Parsed commandline 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: str or None (optional, set by web backend)
Raises: Raises:
RuntimeError: If no audio stream is found. RuntimeError: If no audio stream is found.
ValueError: If timestamp parsing or format compatibility fails. ValueError: If compatibility validation fails.
""" """
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 1. Setup # 1. Setup
@@ -42,26 +80,58 @@ def split_audio(input_file, output_directory, tracks, args):
raise RuntimeError("No audio stream found in input file.") 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) user_container = getattr(args, 'container', None)
print(f"Output container: {output_format}") output_container = determine_output_format(
stream_info,
validate_format_compatibility(output_format, stream_info, user_format=user_container,
args.drop_video, args.drop_subs) transcode_audio=args.audio_codec if args.audio_codec != 'copy' else None,
input_file=input_file
# Determine file extension. )
extension_info = FORMAT_INFO.get(output_format, {}) print(f"Output container: {output_container}")
extension = extension_info.get('ext', '.mkv')
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 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 audio codec if possible, otherwise fallback to container default
if output_audio_codec in CODEC_TO_EXTENSION_MAP:
extension = CODEC_TO_EXTENSION_MAP[output_audio_codec]
else:
extension = FORMAT_INFO.get(output_container, {}).get('ext', '.mkv')
print(f"Output extension: {extension}")
# --------------------------------------------------------------------------
# 5. Parse timestamps
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
track_times = parse_track_timestamps(tracks) track_times = parse_track_timestamps(tracks)
resolved_times = resolve_end_times(track_times, total_duration) 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) input_metadata = get_metadata(input_file)
original_album = input_metadata.get('album') original_album = input_metadata.get('album')
@@ -78,7 +148,22 @@ def split_audio(input_file, output_directory, tracks, args):
print(f" Stream {idx}: '{comment}'") print(f" Stream {idx}: '{comment}'")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 5. Process each track # 7. Handle attached picture (cover art)
# --------------------------------------------------------------------------
cover_image_path = getattr(args, 'cover_image', None)
if cover_image_path and not os.path.exists(cover_image_path):
cover_image_path = None
if not cover_image_path 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):
print("Extracted cover image for all tracks.")
else:
cover_image_path = None
# --------------------------------------------------------------------------
# 8. Process each track
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
for idx, track in enumerate(tracks, start=1): for idx, track in enumerate(tracks, start=1):
start_seconds, end_seconds = resolved_times[idx - 1] start_seconds, end_seconds = resolved_times[idx - 1]
@@ -91,31 +176,53 @@ def split_audio(input_file, output_directory, tracks, args):
track_name = track.get('tn', 'Unknown') track_name = track.get('tn', 'Unknown')
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5a. Build filename # 8a. Build filename
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
clean_filename = build_filename(track, idx, extension, args) clean_filename = build_filename(track, idx, extension, args)
output_path = os.path.join(output_directory, clean_filename) 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): if args.skip_existing and os.path.exists(output_path):
print(f"Skipping track {idx}: {output_path} already exists.") print(f"Skipping track {idx}: {output_path} already exists.")
continue continue
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5c. Build metadata # 8c. Build metadata
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
metadata = build_metadata_dict(track, idx, input_metadata, args) 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
cmd = build_ffmpeg_command( cmd = build_ffmpeg_command(
input_file, start_seconds, duration_seconds, output_path, input_file=input_file,
stream_info, output_format, args.transcode_to, start_seconds=start_seconds,
args.drop_video, args.drop_subs, duration_seconds=duration_seconds,
metadata=metadata 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=cover_image_path,
video_quality=getattr(args, 'video_quality', None),
drop_video=args.drop_video,
drop_subs=args.drop_subs,
) )
print(f"Extracting track {idx}: {track_name} " print(f"Extracting track {idx}: {track_name} "
@@ -128,3 +235,13 @@ def split_audio(input_file, output_directory, tracks, args):
print(result.stderr) print(result.stderr)
else: else:
print(f" -> Saved to: {output_path}") print(f" -> Saved 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}")
+193 -51
View File
@@ -1,4 +1,5 @@
"""FFmpeg/FFprobe interaction utilities for the CLI and web backend.""" # audio_splitter/ffmpeg.py
"""FFmpeg/FFprobe interaction utilities."""
import json import json
import subprocess import subprocess
@@ -75,6 +76,28 @@ def get_audio_codec(input_file: str) -> Optional[str]:
return codec if codec else None 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]: def get_stream_info(input_file: str) -> Dict[str, any]:
""" """
Collect information about the streams present in the input file. Collect information about the streams present in the input file.
@@ -156,33 +179,112 @@ def get_container_format(input_file: str) -> Optional[str]:
format_name = result.stdout.strip().split(',')[0] # take first if multiple format_name = result.stdout.strip().split(',')[0] # take first if multiple
if not format_name: if not format_name:
return None return None
# Normalize common aliases to names used in FORMAT_INFO (container only, not codec-specific) # Normalize common aliases to names used in FORMAT_INFO
# This mapping is purely for container identification.
mapping = { mapping = {
'mpeg': 'mp3', # MPEG-1/2 audio (MP3) container 'mpeg': 'mp3',
'mp2': 'mp3', 'mp2': 'mp3',
'mp4': 'mp4', 'mp4': 'mp4',
'm4a': 'mp4', # M4A is MP4 container 'm4a': 'mp4',
'mov': 'mp4', # QuickTime is MP4-like 'mov': 'mp4',
'3gp': 'mp4', '3gp': 'mp4',
'matroska': 'matroska', 'matroska': 'matroska',
'webm': 'matroska', # WebM uses Matroska container 'webm': 'matroska',
'ogg': 'ogg', 'ogg': 'ogg',
'flac': 'flac', 'flac': 'flac',
'wav': 'wav', 'wav': 'wav',
'aac': 'aac', 'aac': 'aac',
'opus': 'opus', 'opus': 'opus',
'mp3': 'mp3', 'mp3': 'mp3',
'adts': 'aac', # raw AAC in ADTS container 'adts': 'aac',
'amr': 'amr', # AMR container (rare) 'amr': 'amr',
} }
return mapping.get(format_name, format_name) return mapping.get(format_name, format_name)
def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: int, def is_attached_picture(input_file: str) -> bool:
output_path: str, stream_info: Dict, format_opt: Optional[str], """
transcode_audio: Optional[str], drop_video: bool, drop_subs: bool, Check if the input file has a video stream that is an attached picture (cover art).
metadata: Optional[Dict] = None) -> List[str]:
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_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. Construct the FFmpeg command line as a list of arguments.
@@ -192,21 +294,30 @@ def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds:
duration_seconds: Duration of the segment (in seconds). duration_seconds: Duration of the segment (in seconds).
output_path: Destination path for the output file. output_path: Destination path for the output file.
stream_info: Dictionary from get_stream_info(). stream_info: Dictionary from get_stream_info().
format_opt: Output container format (e.g., 'mp3'). format_opt: Output container format (e.g., 'mp3', 'mkv').
transcode_audio: Audio codec to transcode to (or None). audio_codec: Audio codec to use ('copy' or encoder name like 'libopus').
drop_video: True to remove video streams. video_codec: Video codec to use ('copy' or encoder name).
drop_subs: True to remove subtitle streams. subtitle_codec: Subtitle codec to use ('copy' or encoder name).
metadata: Optional dict of metadata key/value pairs to write. 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: Returns:
A list of commandline arguments suitable for subprocess.run(). A list of commandline arguments suitable for subprocess.run().
""" """
cmd = [ cmd = ['ffmpeg']
'ffmpeg',
'-i', input_file, # Add cover image as first input if provided
'-ss', format_time(start_seconds), if cover_image_path:
'-t', format_time(duration_seconds) cmd.extend(['-i', cover_image_path])
]
# Add main input file
cmd.extend(['-i', input_file])
# Time options
cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)])
# Clear all original metadata. # Clear all original metadata.
cmd.append('-map_metadata') cmd.append('-map_metadata')
@@ -218,39 +329,70 @@ def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds:
if value is not None and value != '': if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"]) cmd.extend(['-metadata', f"{key}={value}"])
# Stream mapping. # ---------- Stream mapping and codecs ----------
if drop_video and drop_subs: if cover_image_path:
cmd.extend(['-map', '0:a:0']) # We have two inputs: index 0 = cover image, index 1 = main input
elif drop_video: # Map audio from main input (index 1) and video from cover image (index 0)
cmd.extend(['-map', '0:a:0', '-map', '0:s?']) cmd.extend(['-map', '1:a:0', '-map', '0:v:0'])
elif drop_subs:
cmd.extend(['-map', '0:a:0', '-map', '0:v:0'])
else:
cmd.extend(['-map', '0'])
# Audio codec. # Video codec: use user-specified codec if provided, otherwise fallback to png
if transcode_audio: if video_codec and video_codec != 'copy':
cmd.extend(['-c:a', transcode_audio]) cmd.extend(['-c:v', video_codec])
if transcode_audio in ('libmp3lame', 'mp3'): else:
cmd.extend(['-b:a', '192k']) cmd.extend(['-c:v', 'png'])
elif transcode_audio in ('libopus', 'opus'):
cmd.extend(['-b:a', '128k'])
else:
cmd.extend(['-c:a', 'copy'])
# Video codec. # Audio codec
if not drop_video and stream_info['has_video']: if audio_codec and audio_codec != 'copy':
cmd.extend(['-c:v', 'copy']) cmd.extend(['-c:a', audio_codec])
else: else:
cmd.append('-vn') cmd.extend(['-c:a', 'copy'])
# Subtitle codec. # Subtitle: we don't copy subtitles when using cover image (they would be from main input)
if not drop_subs and stream_info['has_subtitle']:
cmd.extend(['-c:s', 'copy'])
else:
cmd.append('-sn') cmd.append('-sn')
# Output format. # Video quality if specified
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
else:
# Standard mapping (no cover image)
if drop_video and drop_subs:
cmd.extend(['-map', '0:a:0'])
elif drop_video:
cmd.extend(['-map', '0:a:0', '-map', '0:s?'])
elif drop_subs:
cmd.extend(['-map', '0:a:0', '-map', '0:v:0'])
else:
cmd.extend(['-map', '0'])
# Audio codec
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.get('has_video'):
if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', video_codec])
# Add video quality if specified (only when re-encoding)
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.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
if format_opt: if format_opt:
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt) ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
cmd.extend(['-f', ffmpeg_format]) cmd.extend(['-f', ffmpeg_format])
+60 -63
View File
@@ -1,16 +1,25 @@
# audio_splitter/formats.py
"""Container format decision and validation.""" """Container format decision and validation."""
from typing import Dict, Optional from typing import Dict, Optional
from .constants import FORMAT_INFO from .constants import (
FORMAT_INFO,
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]: def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
""" """
Given the container format and audio codec, determine the recommended output format. Given the container format and audio codec, determine the recommended output format.
This is used when the user has not explicitly specified a format. Uses the CODEC_TO_CONTAINER_MAP to map codec → container.
It prioritizes the codec to choose the most appropriate container/extension. If the codec is not found, it falls back to the container.
Args: Args:
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format(). container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format().
@@ -22,37 +31,13 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) ->
if not container: if not container:
return None return None
# Codec-based decisions (highest priority) if codec and codec in CODEC_TO_CONTAINER_MAP:
if codec == 'opus': fmt = CODEC_TO_CONTAINER_MAP[codec]
return 'opus' if fmt in FORMAT_INFO:
if codec in ('aac', 'alac', 'he-aac'): return fmt
return 'm4a'
if codec == 'mp3':
return 'mp3'
if codec == 'vorbis':
return 'ogg'
if codec == 'flac':
return 'flac'
# Container-based fallback (lower priority) if container in FORMAT_INFO:
if container in ('mp4', 'm4a', 'mov', '3gp'): return container
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'
return None return None
@@ -68,63 +53,75 @@ def determine_output_format(stream_info: Dict, user_format: Optional[str],
- MKV if video/subtitles exist - MKV if video/subtitles exist
- MP3 if the audio codec is MP3 - MP3 if the audio codec is MP3
- MP4 (M4A) otherwise - MP4 (M4A) otherwise
Args:
stream_info: Dict from get_stream_info().
user_format: Userrequested 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: if user_format:
return user_format return user_format
# If input_file is provided, try to detect container and codec
if input_file: if input_file:
try: try:
from .ffmpeg import get_container_format, get_audio_codec from .ffmpeg import get_container_format, get_audio_codec
container = get_container_format(input_file) container = get_container_format(input_file)
codec = get_audio_codec(input_file) audio_codec = get_audio_codec(input_file)
fmt = determine_default_format(container, codec) fmt = determine_default_format(container, audio_codec)
if fmt in FORMAT_INFO: if fmt in FORMAT_INFO:
return fmt return fmt
except Exception: except Exception:
# If detection fails, fall through to legacy logic
pass pass
# Fallback: legacy behavior # Fallback
if stream_info.get('has_video') or stream_info.get('has_subtitle'): if stream_info.get('has_video') or stream_info.get('has_subtitle'):
return 'matroska' return 'matroska'
audio_codec = stream_info.get('audio_codec', '') audio_codec = stream_info.get('audio_codec', '')
if audio_codec == 'mp3': if audio_codec == 'mp3':
return 'mp3' return 'mp3'
else: else:
return 'mp4' # .m4a return 'mp4'
def validate_format_compatibility(format_name: str, stream_info: Dict, def validate_format_compatibility(
drop_video: bool, drop_subs: bool) -> None: 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: Raises:
ValueError: If the format is incompatible with the intended streams. ValueError: If the combination is incompatible.
""" """
info = FORMAT_INFO.get(format_name) info = FORMAT_INFO.get(container)
if not info: 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 return
if info['audio_only']: # Check if container is audio-only and video is present (unless dropped)
if stream_info.get('has_video') and not drop_video: if info['audio_only'] 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)."
)
# Check if audio codec is supported
if audio_codec and audio_codec != 'copy':
if not is_audio_codec_supported(container, audio_codec):
raise ValueError( raise ValueError(
f"Format '{format_name}' does not support video streams. " f"Container '{container}' does not support audio codec '{audio_codec}'. "
"Please use --drop-video or choose a container that supports video." f"Please choose a different container or audio codec."
)
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."
) )
# 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)
if video_codec and video_codec != 'copy':
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."
)
+146 -55
View File
@@ -1,11 +1,18 @@
"""Commandline interface and entry point.""" #!/usr/bin/env python3
"""
Audio Splitter CommandLine Interface
Split an audio file into tracks using a tracklist file.
Usage:
audio_splitter input.mp3 tracklist.txt [OPTIONS]
"""
import argparse
import os import os
import sys import sys
import subprocess import subprocess
import argparse
from .constants import DEFAULT_BAD_CHARS
from .defaults import ( from .defaults import (
DEFAULT_FORMAT, DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE, DEFAULT_OUTPUT_TEMPLATE,
@@ -24,50 +31,152 @@ from .defaults import (
DEFAULT_REPLACE_BAD_CHARS, DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING, DEFAULT_SKIP_EXISTING,
DEFAULT_DELETE_ORIGINAL, DEFAULT_DELETE_ORIGINAL,
DEFAULT_TRANSCODE_TO,
) )
from .core import split_audio from .core import split_audio
from .tracklist import read_tracklist, parse_format from .tracklist import read_tracklist, parse_format
def main(): 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('input_file', help='Input audio file')
parser.add_argument('tracklist_file', help='Tracklist file') parser.add_argument('tracklist_file', help='Tracklist file')
# Output options # Container and codec options
parser.add_argument('--format', default=DEFAULT_FORMAT, help=f"Output container format (default: {DEFAULT_FORMAT})") parser.add_argument(
parser.add_argument('--transcode-to', default=DEFAULT_TRANSCODE_TO, help="Audio codec to transcode to (default: copy)") '--container',
parser.add_argument('--drop-video', action='store_true', default=DEFAULT_DROP_VIDEO, help="Drop video streams") default=None,
parser.add_argument('--drop-subs', action='store_true', default=DEFAULT_DROP_SUBS, help="Drop subtitle streams") 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 # Filename options
parser.add_argument('--number-tracks', action='store_true', default=DEFAULT_NUMBER_TRACKS, help="Prepend track numbers") parser.add_argument(
parser.add_argument('--output-template', default=DEFAULT_OUTPUT_TEMPLATE, help=f"Output filename template (default: {DEFAULT_OUTPUT_TEMPLATE})") '--number-tracks',
parser.add_argument('--replace-bad-chars', action='store_true', default=DEFAULT_REPLACE_BAD_CHARS, help="Replace bad characters") action='store_true',
parser.add_argument('--replacement-char', default=DEFAULT_REPLACEMENT_CHAR, help=f"Replacement character (default: {DEFAULT_REPLACEMENT_CHAR})") default=DEFAULT_NUMBER_TRACKS,
parser.add_argument('--bad-chars', default=DEFAULT_BAD_CHARS, help=f"Bad characters to replace (default: {DEFAULT_BAD_CHARS})") help="Prepend track numbers"
parser.add_argument('--skip-existing', action='store_true', default=DEFAULT_SKIP_EXISTING, help="Skip existing output files") )
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('--album', default=DEFAULT_ALBUM, help="Album name")
parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment") 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(
parser.add_argument('--comment-stream', type=int, default=DEFAULT_COMMENT_STREAM, help="Comment stream index") '--no-comment',
parser.add_argument('--merge-comments', action='store_true', default=DEFAULT_MERGE_COMMENTS, help="Merge all comments") action='store_true',
parser.add_argument('--comment-separator', default=DEFAULT_COMMENT_SEPARATOR, help=f"Separator for merged comments (default: {DEFAULT_COMMENT_SEPARATOR})") 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 # 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)"
)
# Other # Other
parser.add_argument('--delete-original', action='store_true', default=DEFAULT_DELETE_ORIGINAL, help="Delete original file after split") parser.add_argument(
parser.add_argument('--dry-run', action='store_true', help="Parse and display tracklist without splitting") '--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() args = parser.parse_args()
# -------------------------------------------------------------------------- # Validate input
# Input validation
# --------------------------------------------------------------------------
if not os.path.exists(args.input_file): if not os.path.exists(args.input_file):
print(f"Error: Input file not found: {args.input_file}") print(f"Error: Input file not found: {args.input_file}")
sys.exit(1) sys.exit(1)
@@ -76,12 +185,7 @@ def main():
print(f"Error: Tracklist file not found: {args.tracklist_file}") print(f"Error: Tracklist file not found: {args.tracklist_file}")
sys.exit(1) sys.exit(1)
# Ensure the replacement character is a single character. # Check FFmpeg
if len(args.replacement_char) != 1:
print("Error: --replacement-char must be a single character.")
sys.exit(1)
# Check that FFmpeg is installed.
try: try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True) subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
@@ -89,14 +193,14 @@ def main():
print(" - https://ffmpeg.org/download.html") print(" - https://ffmpeg.org/download.html")
sys.exit(1) sys.exit(1)
# Parse the tracklist using the userprovided format. # Parse tracklist format
try: try:
tokens = parse_format(args.tracklist_format) tokens = parse_format(args.tracklist_format)
except ValueError as error: except ValueError as e:
print(f"Error in --tracklist-format: {error}") print(f"Error in --tracklist-format: {e}")
sys.exit(1) sys.exit(1)
# Read and parse the tracklist file. # Read tracklist
tracks = read_tracklist(args.tracklist_file, tokens) tracks = read_tracklist(args.tracklist_file, tokens)
if not tracks: if not tracks:
print("Error: No valid tracks found in tracklist file.") print("Error: No valid tracks found in tracklist file.")
@@ -104,7 +208,6 @@ def main():
print(f"Found {len(tracks)} tracks.") print(f"Found {len(tracks)} tracks.")
# Dryrun mode: display parsed data and exit.
if args.dry_run: if args.dry_run:
print("\nParsed tracklist:") print("\nParsed tracklist:")
print("-" * 60) print("-" * 60)
@@ -118,34 +221,22 @@ def main():
print(f"{idx:3d} | " + " | ".join(values)) print(f"{idx:3d} | " + " | ".join(values))
print("-" * 60) print("-" * 60)
print("Dryrun complete. No files were created.") print("Dryrun complete. No files were created.")
sys.exit(0) return
# Determine the output directory. # Determine output directory
if args.output_dir: if args.output_dir:
output_dir = args.output_dir output_dir = args.output_dir
print(f"Using custom output directory: {output_dir}")
else: else:
base_name = os.path.splitext(os.path.basename(args.input_file))[0] base_name = os.path.splitext(os.path.basename(args.input_file))[0]
output_dir = base_name + "_splits" output_dir = base_name + "_splits"
print(f"Using default output directory: {output_dir}")
# Run the splitter. # Run split
try: try:
split_audio(args.input_file, output_dir, tracks, args) split_audio(args.input_file, output_dir, tracks, args)
except (RuntimeError, ValueError) as error: except Exception as e:
print(f"Error: {error}") print(f"Error during split: {e}")
sys.exit(1) 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!") print("Done!")
+1 -1
View File
@@ -1,3 +1,3 @@
"""API route handlers.""" """API route handlers."""
from . import upload, split, split_file, status, download, formats, info from . import upload, split, split_file, status, download, websocket, formats, info
+5 -11
View File
@@ -1,8 +1,9 @@
# web/backend/api/formats.py
"""Endpoint to expose format information to the frontend.""" """Endpoint to expose format information to the frontend."""
from fastapi import APIRouter from fastapi import APIRouter
from backend.constants import FORMAT_INFO from audio_splitter.constants import CONTAINER_INFO, CODEC_INFO
router = APIRouter(prefix="/api", tags=["formats"]) router = APIRouter(prefix="/api", tags=["formats"])
@@ -10,16 +11,9 @@ router = APIRouter(prefix="/api", tags=["formats"])
@router.get("/formats") @router.get("/formats")
async def get_formats(): async def get_formats():
""" """
Return the list of supported container formats with their properties. Return the list of supported containers and codecs.
""" """
return { return {
"formats": [ "containers": CONTAINER_INFO,
{ "codecs": CODEC_INFO,
"name": name,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
} }
+30 -2
View File
@@ -1,4 +1,4 @@
"""Split task endpoint.""" """Split task endpoint (JSON tracklist)."""
from fastapi import APIRouter, HTTPException, BackgroundTasks 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']}" 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_manager.update_task(
task_id, task_id,
status=TaskStatus.PROCESSING, status=TaskStatus.PROCESSING,
@@ -31,7 +54,12 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
message="Preparing to split..." 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( return SplitResponse(
task_id=task_id, task_id=task_id,
+48 -37
View File
@@ -2,6 +2,7 @@
import json import json
from pathlib import Path from pathlib import Path
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks 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"]) router = APIRouter(prefix="/api", tags=["split"])
# Import CLI tracklist parsing functions
from audio_splitter.tracklist import parse_format, read_tracklist from audio_splitter.tracklist import parse_format, read_tracklist
@@ -22,29 +22,33 @@ async def split_from_file(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
task_id: str = Form(...), task_id: str = Form(...),
tracklist_file: UploadFile = File(...), 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"), 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 # Validate task exists
if not task_manager.has_task(task_id): if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found") 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) status = task_manager.get_status(task_id)
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE): if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
raise HTTPException( raise HTTPException(
@@ -52,14 +56,12 @@ async def split_from_file(
detail=f"Task {task_id} is already {status['status']}" detail=f"Task {task_id} is already {status['status']}"
) )
# Validate file type
if not tracklist_file.filename.endswith(('.txt', '.text')): if not tracklist_file.filename.endswith(('.txt', '.text')):
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Tracklist file must be a text file (.txt or .text)" detail="Tracklist file must be a text file (.txt or .text)"
) )
# Read and parse the tracklist file
try: try:
content = await tracklist_file.read() content = await tracklist_file.read()
text = content.decode('utf-8') text = content.decode('utf-8')
@@ -75,17 +77,13 @@ async def split_from_file(
detail="Tracklist file is empty" detail="Tracklist file is empty"
) )
# Parse the tracklist using CLI logic # Parse tracklist using CLI logic
try: try:
# Parse the format string into tokens
tokens = parse_format(tracklist_format) 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 = settings.temp_dir / task_id / "tracklist.txt"
temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True) temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True)
temp_tracklist_path.write_text(text, encoding='utf-8') 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) tracklist_dicts = read_tracklist(str(temp_tracklist_path), tokens)
except Exception as e: except Exception as e:
@@ -100,7 +98,7 @@ async def split_from_file(
detail="No valid tracks found in tracklist file" detail="No valid tracks found in tracklist file"
) )
# Convert dicts to TracklistEntry objects (same as JSON endpoint) # Convert dicts to TracklistEntry objects
try: try:
tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts] tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts]
except Exception as e: except Exception as e:
@@ -109,14 +107,28 @@ async def split_from_file(
detail=f"Invalid tracklist data: {str(e)}" detail=f"Invalid tracklist data: {str(e)}"
) )
# Parse options JSON # Build options dict from explicit fields (ignore `options` parameter)
try: options = {
options_dict = json.loads(options) "container": container,
except json.JSONDecodeError: "audio_codec": audio_codec,
raise HTTPException( "video_codec": video_codec,
status_code=400, "subtitle_codec": subtitle_codec,
detail="Invalid JSON in options field" "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 # Update task status
task_manager.update_task( task_manager.update_task(
@@ -126,12 +138,11 @@ async def split_from_file(
message="Preparing to split..." message="Preparing to split..."
) )
# Start background task
background_tasks.add_task( background_tasks.add_task(
run_split_task, run_split_task,
task_id, task_id,
tracklist_entries, # now TracklistEntry objects tracklist_entries,
options_dict options
) )
return SplitResponse( return SplitResponse(
+27 -5
View File
@@ -17,19 +17,16 @@ class TracklistEntry(BaseModel):
@validator("ts") @validator("ts")
def validate_timestamp(cls, v: str) -> str: def validate_timestamp(cls, v: str) -> str:
"""Basic timestamp validation (format and range)."""
v = v.strip() v = v.strip()
if not v: if not v:
raise ValueError("Timestamp cannot be empty") raise ValueError("Timestamp cannot be empty")
# Check for range format (start-end)
if "-" in v: if "-" in v:
parts = v.split("-", 1) parts = v.split("-", 1)
start = parts[0].strip() start = parts[0].strip()
end = parts[1].strip() end = parts[1].strip()
if not start or not end: if not start or not end:
raise ValueError("Invalid range format. Expected 'start-end'") raise ValueError("Invalid range format. Expected 'start-end'")
# Validate each part with the same logic
for ts in [start, end]: for ts in [start, end]:
cls._validate_single_timestamp(ts) cls._validate_single_timestamp(ts)
else: else:
@@ -39,7 +36,6 @@ class TracklistEntry(BaseModel):
@staticmethod @staticmethod
def _validate_single_timestamp(ts: str) -> None: def _validate_single_timestamp(ts: str) -> None:
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
parts = ts.split(":") parts = ts.split(":")
if len(parts) not in (2, 3): if len(parts) not in (2, 3):
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS") 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") task_id: str = Field(..., description="Task ID from upload")
tracklist: List[TracklistEntry] = Field(..., description="List of tracks") 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")
+48 -45
View File
@@ -2,10 +2,10 @@
import os import os
import sys import sys
import time import shutil
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from typing import List, Dict, Any
from typing import Dict, List, Any import time
from backend.config import settings from backend.config import settings
from backend.services.task_manager import task_manager 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.request import TracklistEntry
from backend.models.response import TaskStatus 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: def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None:
try: 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) input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists(): 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) output_dir = FileManager.ensure_output_dir(task_id)
# Convert tracklist to dicts (CLI format)
tracks = [] tracks = []
for entry in tracklist: for entry in tracklist:
track_dict = { track_dict = {
@@ -58,25 +38,30 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
} }
tracks.append(track_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( args = SimpleNamespace(
format=options.get("format", DEFAULT_FORMAT), container=options.get("container"), # None -> auto-detect
transcode_to=options.get("transcode_to", DEFAULT_TRANSCODE_TO), audio_codec=options.get("audio_codec", "copy"),
drop_video=options.get("drop_video", DEFAULT_DROP_VIDEO), video_codec=options.get("video_codec", "copy"),
drop_subs=options.get("drop_subs", DEFAULT_DROP_SUBS), subtitle_codec=options.get("subtitle_codec", "copy"),
number_tracks=options.get("number_tracks", DEFAULT_NUMBER_TRACKS), video_quality=options.get("video_quality"),
replace_bad_chars=options.get("replace_bad_chars", DEFAULT_REPLACE_BAD_CHARS), drop_video=options.get("drop_video", False),
replacement_char=options.get("replacement_char", DEFAULT_REPLACEMENT_CHAR), drop_subs=options.get("drop_subs", False),
bad_chars=options.get("bad_chars", DEFAULT_BAD_CHARS), number_tracks=options.get("number_tracks", False),
skip_existing=options.get("skip_existing", DEFAULT_SKIP_EXISTING), replace_bad_chars=options.get("replace_bad_chars", False),
output_template=options.get("output_template", DEFAULT_OUTPUT_TEMPLATE), replacement_char=options.get("replacement_char", "_"),
album=options.get("album", DEFAULT_ALBUM), bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
comment=options.get("comment", DEFAULT_COMMENT), skip_existing=options.get("skip_existing", False),
no_comment=options.get("no_comment", DEFAULT_NO_COMMENT), output_template=options.get("output_template", "%an-%tn.%ext"),
comment_stream=options.get("comment_stream", DEFAULT_COMMENT_STREAM), album=options.get("album", None),
merge_comments=options.get("merge_comments", DEFAULT_MERGE_COMMENTS), comment=options.get("comment", None),
comment_separator=options.get("comment_separator", DEFAULT_COMMENT_SEPARATOR), no_comment=options.get("no_comment", False),
delete_original=DEFAULT_DELETE_ORIGINAL, # never delete in web 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 project_root = Path(__file__).parent.parent.parent.parent
@@ -85,7 +70,25 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from audio_splitter.core import split_audio from audio_splitter.core import split_audio
task_manager.update_task_with_progress(task_id, progress=10, message="Starting split...") # Detect attached picture and extract if applicable
cover_image_path = None
if not args.drop_video:
from audio_splitter.ffmpeg import is_attached_picture, extract_cover_image, get_stream_info
input_path_str = str(input_path)
stream_info = get_stream_info(input_path_str)
if stream_info.get('has_video') and is_attached_picture(input_path_str):
cover_image_path = output_dir / 'cover.png'
if extract_cover_image(input_path_str, str(cover_image_path)):
print(f"Extracted cover image for task {task_id}")
else:
cover_image_path = None
# Attach cover image to args
args.cover_image = str(cover_image_path) if cover_image_path else None
task_manager.update_task_with_progress(
task_id, progress=10, message="Starting split..."
)
split_audio(str(input_path), str(output_dir), tracks, args) split_audio(str(input_path), str(output_dir), tracks, args)
+7 -8
View File
@@ -68,16 +68,15 @@ const App: React.FC = () => {
reset() reset()
} }
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError const isSplitDisabled =
!taskId ||
!isValid ||
entries.length === 0 ||
isProcessing ||
!!formatError
return ( return (
<ThemeProvider <ThemeProvider theme={createTheme({ palette: { mode: theme } })}>
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<CssBaseline /> <CssBaseline />
<Layout> <Layout>
<Grid container spacing={3}> <Grid container spacing={3}>
+34 -10
View File
@@ -1,5 +1,12 @@
import axios from 'axios' import axios from 'axios'
import { TracklistEntry, SplitOptions, TaskStatus } from '../types' import {
TracklistEntry,
SplitOptions,
TaskStatus,
FormatsResponse,
UploadResponse,
SplitResponse,
} from '../types'
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', 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() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const response = await api.post('/upload', formData, { const response = await api.post('/upload', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}) })
return response.data return response.data
} }
@@ -25,12 +30,32 @@ export const startSplit = async (
task_id: string, task_id: string,
tracklist: TracklistEntry[], tracklist: TracklistEntry[],
options: SplitOptions options: SplitOptions
): Promise<{ task_id: string; status: string }> => { ): Promise<SplitResponse> => {
const response = await api.post('/split', { // Build request payload (only send fields that are not undefined)
const payload: any = {
task_id, task_id,
tracklist, 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 return response.data
} }
@@ -47,8 +72,7 @@ export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip` return `/api/download/${task_id}/splits.zip`
} }
// New functions for format validation feature export const getFormats = async (): Promise<FormatsResponse> => {
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
const response = await api.get('/formats') const response = await api.get('/formats')
return response.data return response.data
} }
+183 -75
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react' import React, { useEffect, useMemo } from 'react'
import { import {
Box, Box,
Paper, Paper,
@@ -16,7 +16,12 @@ import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore' import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore' import { useValidationStore } from '../stores/validationStore'
import { getFormats } from '../api/client'
import { SplitOptions } from '../types'
// ------------------------------------------------------------------------------
// Section component (collapsible)
// ------------------------------------------------------------------------------
interface SectionProps { interface SectionProps {
title: string title: string
children: React.ReactNode children: React.ReactNode
@@ -25,7 +30,6 @@ interface SectionProps {
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => { const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded) const [expanded, setExpanded] = React.useState(defaultExpanded)
return ( return (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Box <Box
@@ -51,54 +55,116 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
) )
} }
// ------------------------------------------------------------------------------
// Main OptionsPanel
// ------------------------------------------------------------------------------
export const OptionsPanel: React.FC = () => { export const OptionsPanel: React.FC = () => {
const { options, setOptions } = useOptionsStore() const {
options,
setOptions,
containers,
codecs,
compatibility,
setContainers,
setCodecs,
setCompatibility,
} = useOptionsStore()
const { hasVideo } = useUploadStore() const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore() const { formatError, setFormatError } = useValidationStore()
// Audio-only formats from backend constants (hardcoded for now) // Fetch formats on mount
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)
useEffect(() => { useEffect(() => {
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video const fetchFormats = async () => {
const newError = shouldShowError try {
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).` const data = await getFormats()
: null setContainers(data.containers || [])
setCodecs(data.codecs || [])
// Only update if the error state actually changes setCompatibility(data.compatibility || {})
if (newError !== formatError) { } catch (err) {
setFormatError(newError) 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 = useMemo(() => {
const entry = compatibility?.[options.container || '']
if (!entry) return codecs.filter(c => c.supports_transcoding)
const audioList = entry.audio
if (audioList === null) return codecs.filter(c => c.supports_transcoding)
return codecs.filter(c => c.supports_transcoding && audioList.includes(c.name))
}, [compatibility, options.container, codecs])
const filteredVideoCodecs = useMemo(() => {
const entry = compatibility?.[options.container || '']
if (!entry) return codecs.filter(c => c.supports_video)
const videoList = entry.video
if (videoList === null) return codecs.filter(c => c.supports_video)
return codecs.filter(c => c.supports_video && videoList.includes(c.name))
}, [compatibility, options.container, codecs])
// --------------------------------------------------------------
// Validate compatibility
// --------------------------------------------------------------
useEffect(() => {
const selectedContainer = containers.find(c => c.name === options.container)
if (selectedContainer?.audio_only && 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])
// --------------------------------------------------------------
// 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 ( return (
<Paper sx={{ p: 3 }}> <Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
@@ -111,60 +177,101 @@ export const OptionsPanel: React.FC = () => {
</Alert> </Alert>
)} )}
{/* Output Settings */} {/* ------------------------------------------------------------------------
Output Settings
------------------------------------------------------------------------ */}
<Section title="Output Settings" defaultExpanded> <Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Container dropdown */}
<TextField <TextField
label="Container" label="Container"
select select
value={options.format} value={options.container ?? ''}
onChange={handleFormatChange} onChange={handleContainerChange}
fullWidth fullWidth
size="small" size="small"
error={!!formatError} error={!!formatError}
helperText={ 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="">Auto-detect</MenuItem>
<MenuItem value="m4a">M4A (.m4a)</MenuItem> {containers.map((c) => (
<MenuItem value="mkv">MKV (.mkv)</MenuItem> <MenuItem key={c.name} value={c.name}>
<MenuItem value="mp4">MP4 (.mp4)</MenuItem> {c.name.toUpperCase()} ({c.extension})
<MenuItem value="ogg">OGG (.ogg)</MenuItem> </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>
</TextField> </TextField>
{/* Transcode option remains unchanged but clearly labeled */} {/* Audio Codec dropdown */}
<TextField <TextField
label="Audio Codec (Transcode)" label="Audio Codec"
select select
value={options.transcode_to || ''} value={options.audio_codec}
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)} onChange={(e) => handleChange('audio_codec', e.target.value)}
fullWidth fullWidth
size="small" 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="copy">Copy (original)</MenuItem>
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem> {filteredAudioCodecs.map((c) => (
<MenuItem value="aac">AAC</MenuItem> <MenuItem key={c.name} value={c.name}>
<MenuItem value="libopus">OPUS</MenuItem> {c.name.toUpperCase()}
</MenuItem>
))}
</TextField> </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 && ( {hasVideo && (
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
checked={options.drop_video} checked={options.drop_video}
onChange={handleDropVideoChange} onChange={(e) => handleChange('drop_video', e.target.checked)}
/> />
} }
label="Drop video streams" label="Drop video streams"
/> />
)} )}
{/* Drop subtitles */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -177,7 +284,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Filename Settings */} {/* ------------------------------------------------------------------------
Filename Settings
------------------------------------------------------------------------ */}
<Section title="Filename Settings"> <Section title="Filename Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -233,7 +342,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Metadata Settings */} {/* ------------------------------------------------------------------------
Metadata Settings
------------------------------------------------------------------------ */}
<Section title="Metadata Settings"> <Section title="Metadata Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -264,7 +375,7 @@ export const OptionsPanel: React.FC = () => {
type="number" type="number"
value={options.comment_stream ?? ''} value={options.comment_stream ?? ''}
onChange={(e) => 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" size="small"
disabled={options.no_comment} disabled={options.no_comment}
@@ -289,7 +400,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Tracklist Settings */} {/* ------------------------------------------------------------------------
Tracklist Settings
------------------------------------------------------------------------ */}
<Section title="Tracklist Settings" defaultExpanded> <Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -304,9 +417,4 @@ export const OptionsPanel: React.FC = () => {
</Section> </Section>
</Paper> </Paper>
) )
// Helper function for option updates
function handleChange(field: string, value: any) {
setOptions({ [field]: value })
}
} }
+3 -2
View File
@@ -70,9 +70,10 @@ export const UploadZone: React.FC = () => {
// Fetch recommended format and update options // Fetch recommended format and update options
try { try {
// Inside UploadZone.tsx, after fetching recommended format:
const rec = await getRecommendedFormat(taskId) const rec = await getRecommendedFormat(taskId)
// Update only the format; keep other options (e.g., transcode_to) as defaults // Update options store with container (not format)
setOptions({ format: rec.format }) setOptions({ container: rec.format })
} catch (err) { } catch (err) {
console.warn('Failed to fetch recommended format, using default', err) console.warn('Failed to fetch recommended format, using default', err)
} }
+27 -7
View File
@@ -1,6 +1,5 @@
// web/frontend/src/stores/optionsStore.ts
import { create } from 'zustand' import { create } from 'zustand'
import { SplitOptions } from '../types' import { SplitOptions, ContainerInfo, CodecInfo } from '../types'
import { import {
DEFAULT_FORMAT, DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE, DEFAULT_OUTPUT_TEMPLATE,
@@ -17,13 +16,16 @@ import {
DEFAULT_NUMBER_TRACKS, DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS, DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING, DEFAULT_SKIP_EXISTING,
DEFAULT_TRANSCODE_TO,
DEFAULT_TRACKLIST_FORMAT, DEFAULT_TRACKLIST_FORMAT,
} from '../constants/generated' } from '../constants/generated'
// We use DEFAULT_FORMAT only as a fallback; container default is null (auto-detect)
const DEFAULT_OPTIONS: SplitOptions = { const DEFAULT_OPTIONS: SplitOptions = {
format: DEFAULT_FORMAT, container: null,
transcode_to: DEFAULT_TRANSCODE_TO ?? '', audio_codec: 'copy',
video_codec: 'copy',
subtitle_codec: 'copy',
video_quality: null,
drop_video: DEFAULT_DROP_VIDEO, drop_video: DEFAULT_DROP_VIDEO,
drop_subs: DEFAULT_DROP_SUBS, drop_subs: DEFAULT_DROP_SUBS,
number_tracks: DEFAULT_NUMBER_TRACKS, number_tracks: DEFAULT_NUMBER_TRACKS,
@@ -43,15 +45,33 @@ const DEFAULT_OPTIONS: SplitOptions = {
interface OptionsState { interface OptionsState {
options: SplitOptions 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 reset: () => void
} }
export const useOptionsStore = create<OptionsState>((set) => ({ export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS }, options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
setOptions: (newOptions) => setOptions: (newOptions) =>
set((state) => ({ set((state) => ({
options: { ...state.options, ...newOptions }, 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: {},
}),
})) }))
+35 -3
View File
@@ -22,8 +22,11 @@ export interface TaskStatus {
} }
export interface SplitOptions { export interface SplitOptions {
format: string container: string | null // null = auto-detect
transcode_to?: string 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_video: boolean
drop_subs: boolean drop_subs: boolean
number_tracks: boolean number_tracks: boolean
@@ -38,7 +41,36 @@ export interface SplitOptions {
comment_stream: number | null comment_stream: number | null
merge_comments: boolean merge_comments: boolean
comment_separator: string comment_separator: string
tracklist_format: string tracklist_format: string // for frontend parsing
}
export interface ContainerInfo {
name: string
ffmpeg: string
extension: string
audio_only: boolean
supports_video: boolean
supports_subs: boolean
}
export interface CodecInfo {
name: string
ffmpeg: string
recommended_container: string
recommended_extension: string
supports_transcoding: boolean
supports_video: boolean
}
export interface CompatibilityEntry {
audio: string[] | null
video: string[] | null
}
export interface FormatsResponse {
containers: ContainerInfo[]
codecs: CodecInfo[]
compatibility: Record<string, CompatibilityEntry>
} }
export interface UploadResponse { export interface UploadResponse {