diff --git a/audio_splitter/constants.py b/audio_splitter/constants.py index f608d35..dae9e79 100644 --- a/audio_splitter/constants.py +++ b/audio_splitter/constants.py @@ -1,20 +1,135 @@ -"""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. +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) +""" + +# ------------------------------------------------------------------------------ +# 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 = { - '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}, + container['name']: { + 'ffmpeg': container['ffmpeg'], + 'ext': container['extension'], + 'audio_only': container['audio_only'], + } + for container in CONTAINER_INFO } -# Default characters to replace when --replace-bad-chars is enabled. -# Includes common punctuation, single quote, and a trailing space. -DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' ' +# ------------------------------------------------------------------------------ +# Codec information +# ------------------------------------------------------------------------------ +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'!@#№$;:%^&?*(){}[]\/<>+=~`\' ' diff --git a/audio_splitter/core.py b/audio_splitter/core.py index de5b51e..76eeb74 100644 --- a/audio_splitter/core.py +++ b/audio_splitter/core.py @@ -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 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 ( + FORMAT_INFO, + 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 .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: str or None (optional, set by web backend) Raises: RuntimeError: If no audio stream is found. - ValueError: If timestamp parsing or format compatibility fails. + ValueError: If compatibility validation fails. """ # -------------------------------------------------------------------------- # 1. Setup @@ -42,26 +80,58 @@ 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 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) 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 +148,22 @@ def split_audio(input_file, output_directory, tracks, args): 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): 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') # ---------------------------------------------------------------------- - # 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 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=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} " @@ -128,3 +235,13 @@ def split_audio(input_file, output_directory, tracks, args): print(result.stderr) else: 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}") diff --git a/audio_splitter/ffmpeg.py b/audio_splitter/ffmpeg.py index e6570ca..b5119b4 100644 --- a/audio_splitter/ffmpeg.py +++ b/audio_splitter/ffmpeg.py @@ -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 subprocess @@ -75,6 +76,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. @@ -156,33 +179,112 @@ 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 FORMAT_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 + 'webm': 'matroska', '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]: +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_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. @@ -192,21 +294,30 @@ def build_ffmpeg_command(input_file: str, start_seconds: int, duration_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. + 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', - '-i', input_file, - '-ss', format_time(start_seconds), - '-t', format_time(duration_seconds) - ] + cmd = ['ffmpeg'] + + # Add cover image as first input if provided + if cover_image_path: + 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. 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 != '': cmd.extend(['-metadata', f"{key}={value}"]) - # Stream mapping. - 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']) + # ---------- Stream mapping and codecs ---------- + if cover_image_path: + # We have two inputs: index 0 = cover image, index 1 = main input + # Map audio from main input (index 1) and video from cover image (index 0) + cmd.extend(['-map', '1:a:0', '-map', '0:v: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']) - else: - cmd.extend(['-c:a', 'copy']) + # Video codec: use user-specified codec if provided, otherwise fallback to png + if video_codec and video_codec != 'copy': + cmd.extend(['-c:v', video_codec]) + else: + cmd.extend(['-c:v', 'png']) - # Video codec. - if not drop_video and stream_info['has_video']: - cmd.extend(['-c:v', 'copy']) - else: - cmd.append('-vn') + # Audio codec + if audio_codec and audio_codec != 'copy': + cmd.extend(['-c:a', audio_codec]) + else: + cmd.extend(['-c:a', 'copy']) - # Subtitle codec. - if not drop_subs and stream_info['has_subtitle']: - cmd.extend(['-c:s', 'copy']) - else: + # Subtitle: we don't copy subtitles when using cover image (they would be from main input) 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: ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt) cmd.extend(['-f', ffmpeg_format]) diff --git a/audio_splitter/formats.py b/audio_splitter/formats.py index 6aaae08..3af08d9 100644 --- a/audio_splitter/formats.py +++ b/audio_splitter/formats.py @@ -1,16 +1,25 @@ +# audio_splitter/formats.py """Container format decision and validation.""" 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]: """ 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(). @@ -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 FORMAT_INFO: + 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 FORMAT_INFO: + return container return None @@ -68,63 +53,75 @@ def determine_output_format(stream_info: Dict, user_format: Optional[str], - 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: 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) + audio_codec = get_audio_codec(input_file) + fmt = determine_default_format(container, audio_codec) if fmt in FORMAT_INFO: 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' 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 = FORMAT_INFO.get(container) 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 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( - 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 '{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) + 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." + ) diff --git a/audio_splitter/main.py b/audio_splitter/main.py index 4c83bc9..fce53e3 100644 --- a/audio_splitter/main.py +++ b/audio_splitter/main.py @@ -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,152 @@ from .defaults import ( DEFAULT_REPLACE_BAD_CHARS, DEFAULT_SKIP_EXISTING, DEFAULT_DELETE_ORIGINAL, - DEFAULT_TRANSCODE_TO, ) from .core import split_audio from .tracklist import read_tracklist, parse_format + 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)" + ) # 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: _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 +185,7 @@ 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.") - 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 +193,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 +208,6 @@ def main(): print(f"Found {len(tracks)} tracks.") - # Dry‑run mode: display parsed data and exit. if args.dry_run: print("\nParsed tracklist:") print("-" * 60) @@ -118,34 +221,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!") diff --git a/web/backend/api/__init__.py b/web/backend/api/__init__.py index ae73d86..e93b233 100644 --- a/web/backend/api/__init__.py +++ b/web/backend/api/__init__.py @@ -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 diff --git a/web/backend/api/formats.py b/web/backend/api/formats.py index 0c0a995..6c88699 100644 --- a/web/backend/api/formats.py +++ b/web/backend/api/formats.py @@ -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 router = APIRouter(prefix="/api", tags=["formats"]) @@ -10,16 +11,9 @@ 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 and codecs. """ 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, } diff --git a/web/backend/api/split.py b/web/backend/api/split.py index 9967939..a3b2e64 100644 --- a/web/backend/api/split.py +++ b/web/backend/api/split.py @@ -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 - ) \ No newline at end of file + ) diff --git a/web/backend/api/split_file.py b/web/backend/api/split_file.py index 1c0a354..70e8627 100644 --- a/web/backend/api/split_file.py +++ b/web/backend/api/split_file.py @@ -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( diff --git a/web/backend/models/request.py b/web/backend/models/request.py index 73ec21b..bbc8e83 100644 --- a/web/backend/models/request.py +++ b/web/backend/models/request.py @@ -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") \ No newline at end of file + + # 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") diff --git a/web/backend/services/__init__.py b/web/backend/services/__init__.py index ee78a45..de2060f 100644 --- a/web/backend/services/__init__.py +++ b/web/backend/services/__init__.py @@ -1 +1 @@ -"""Business logic services.""" \ No newline at end of file +"""Business logic services.""" diff --git a/web/backend/services/splitter.py b/web/backend/services/splitter.py index 6c9b501..fa82df8 100644 --- a/web/backend/services/splitter.py +++ b/web/backend/services/splitter.py @@ -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,25 @@ 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...") + # 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) diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 7c48eac..52a73b9 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -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 ( - + diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts index 8677837..aa6b0e6 100644 --- a/web/frontend/src/api/client.ts +++ b/web/frontend/src/api/client.ts @@ -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 => { 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 => { + // 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 => { const response = await api.get('/formats') return response.data } diff --git a/web/frontend/src/components/OptionsPanel.tsx b/web/frontend/src/components/OptionsPanel.tsx index 0fd3ef8..eaec8a2 100644 --- a/web/frontend/src/components/OptionsPanel.tsx +++ b/web/frontend/src/components/OptionsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useMemo } from 'react' import { Box, Paper, @@ -16,7 +16,12 @@ 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' +// ------------------------------------------------------------------------------ +// Section component (collapsible) +// ------------------------------------------------------------------------------ interface SectionProps { title: string children: React.ReactNode @@ -25,7 +30,6 @@ interface SectionProps { const Section: React.FC = ({ title, children, defaultExpanded = false }) => { const [expanded, setExpanded] = React.useState(defaultExpanded) - return ( = ({ 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) => { - 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) => { - 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 = 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) => { + const value = e.target.value === '' ? null : e.target.value + handleChange('container', value) + } + + // -------------------------------------------------------------- + // Render + // -------------------------------------------------------------- return ( @@ -111,60 +177,101 @@ export const OptionsPanel: React.FC = () => { )} - {/* Output Settings */} + {/* ------------------------------------------------------------------------ + Output Settings + ------------------------------------------------------------------------ */}
+ {/* Container dropdown */} - MP3 (.mp3) - M4A (.m4a) - MKV (.mkv) - MP4 (.mp4) - OGG (.ogg) - OPUS (.opus) - FLAC (.flac) - WAV (.wav) - AAC (.aac) + Auto-detect + {containers.map((c) => ( + + {c.name.toUpperCase()} ({c.extension}) + + ))} - {/* Transcode option – remains unchanged but clearly labeled */} + {/* Audio Codec dropdown */} 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)" > - Copy (no transcoding) - MP3 (LAME) - AAC - OPUS + Copy (original) + {filteredAudioCodecs.map((c) => ( + + {c.name.toUpperCase()} + + ))} + {/* Video Codec dropdown */} + handleChange('video_codec', e.target.value)} + fullWidth + size="small" + helperText="Video codec (copy = keep original)" + disabled={!hasVideo || options.drop_video} + > + Copy (original) + {filteredVideoCodecs.map((c) => ( + + {c.name.toUpperCase()} + + ))} + + + {/* Video Quality */} + { + 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 && ( handleChange('drop_video', e.target.checked)} /> } label="Drop video streams" /> )} + {/* Drop subtitles */} {
- {/* Filename Settings */} + {/* ------------------------------------------------------------------------ + Filename Settings + ------------------------------------------------------------------------ */}
{
- {/* Metadata Settings */} + {/* ------------------------------------------------------------------------ + Metadata Settings + ------------------------------------------------------------------------ */}
{ 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 +400,9 @@ export const OptionsPanel: React.FC = () => {
- {/* Tracklist Settings */} + {/* ------------------------------------------------------------------------ + Tracklist Settings + ------------------------------------------------------------------------ */}
{
) - - // Helper function for option updates - function handleChange(field: string, value: any) { - setOptions({ [field]: value }) - } } diff --git a/web/frontend/src/components/UploadZone.tsx b/web/frontend/src/components/UploadZone.tsx index c49c47f..62eb5c9 100644 --- a/web/frontend/src/components/UploadZone.tsx +++ b/web/frontend/src/components/UploadZone.tsx @@ -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) } diff --git a/web/frontend/src/stores/optionsStore.ts b/web/frontend/src/stores/optionsStore.ts index ce2970b..4208d63 100644 --- a/web/frontend/src/stores/optionsStore.ts +++ b/web/frontend/src/stores/optionsStore.ts @@ -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) => void + containers: ContainerInfo[] + codecs: CodecInfo[] + compatibility: Record + setOptions: (newOptions: Partial) => void + setContainers: (containers: ContainerInfo[]) => void + setCodecs: (codecs: CodecInfo[]) => void + setCompatibility: (compat: Record) => void reset: () => void } export const useOptionsStore = create((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: {}, + }), })) diff --git a/web/frontend/src/types/index.ts b/web/frontend/src/types/index.ts index 8605082..76437d2 100644 --- a/web/frontend/src/types/index.ts +++ b/web/frontend/src/types/index.ts @@ -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,36 @@ 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 + 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 } export interface UploadResponse {