18 Commits

Author SHA1 Message Date
max 2ab85b64ff fix #26 (backend,frontend): Frontend can't apply codecs filters correctly 2026-09-02 21:24:18 +05:00
max cf0b9f62cb fix #22 (backend,frontend): the backend and frontend had whitelists that only included audio formats 2026-09-02 20:12:00 +05:00
max 1b0767af90 fix #27 (core): no write permissions fail 2026-09-02 19:36:28 +05:00
max a8b0c538ff fix #25 (core): wrong output filename extensions when they are autodetected 2026-09-02 19:14:57 +05:00
max 3a41bd2920 fix #18 (core): malformed names of output files 2026-09-02 18:30:53 +05:00
max 78ec4dd63a Fix #28: warn when timestamps overlap
Add overlap detection in resolve_end_times. When consecutive tracks have
overlapping time ranges (end of track N > start of track N+1), a warning
is printed but processing continues as before.
2026-09-02 16:57:10 +05:00
max 8b67de59a3 Fix #29: validate --container against known containers
Before this fix, passing an invalid container name (e.g., 'nonexistent')
was passed directly to FFmpeg, which would fail with a cryptic error.
Now the container name is validated against CONTAINER_NAMES before any
processing begins, and a clear error message listing valid containers
is shown.
2026-09-02 15:41:38 +05:00
max 25e8c0293e feat (core): allows user to add an external cover image to split tracks 2026-09-02 11:42:47 +05:00
max e36c1d69ed minor (core): updates DEFAULT_BAD_CHARS 2026-09-01 15:54:38 +05:00
max e77434cf30 feat (core): updates codecs info in constants.py 2026-09-01 15:47:02 +05:00
max 90d73d0e74 refact (core): removes useless fields from CODEC_INFO 2026-09-01 13:08:28 +05:00
max 7a7b63b03b refact (core): simplifies constants.py, removes redundancy, updates dependent code 2026-09-01 12:17:21 +05:00
max a67acc31b7 feat (back): removes cover image handling, since it's in core now 2026-08-31 19:50:44 +05:00
max 5f68eccdbf feat (core): If user_format is provided, map it to the correct container if it's a codec name 2026-08-31 19:48:31 +05:00
max 156dfa693f update (core): new values for codec/container/file_extension compatibility 2026-08-31 14:20:02 +05:00
max 580837fa73 feature (core): adds special handlers for audio+cover_image cases 2026-08-31 14:19:07 +05:00
max 747a349877 fix (core): checks the compatibility even if default options are used 2026-08-30 19:00:12 +05:00
max 6f6637a9a2 REFACTOR (BACKEND): removes an useless file 2026-08-30 15:48:28 +05:00
18 changed files with 476 additions and 136 deletions
+45 -56
View File
@@ -11,56 +11,62 @@ This file serves as the single source of truth for:
Codec != Container != File Extension. Codec != Container != File Extension.
Example: Opus (codec) → Ogg (container) → .opus (extension) Example: Opus (codec) → Ogg (container) → .opus (extension)
""" """
from typing import Dict, Optional
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Container information # Container information
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
CONTAINER_INFO = [ CONTAINER_INFO = [
# Audio-only containers # Audio-only containers
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, {'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'supports_video': True, 'supports_subs': False},
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, {'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'supports_video': False, 'supports_subs': False},
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, {'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'supports_video': False, 'supports_subs': False},
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, {'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'supports_video': False, 'supports_subs': False},
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, {'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'supports_video': False, 'supports_subs': False},
# Containers that support video and subtitles # Containers that support video and subtitles
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, {'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'supports_video': True, 'supports_subs': True},
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, {'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', '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', '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', 'supports_video': True, 'supports_subs': False},
{'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'audio_only': False, 'supports_video': True, 'supports_subs': False},
] ]
# Legacy FORMAT_INFO for backward compatibility # Container name lookup set for fast membership checks
FORMAT_INFO = { CONTAINER_NAMES = {container['name'] for container in CONTAINER_INFO}
container['name']: {
'ffmpeg': container['ffmpeg'],
'ext': container['extension'], def get_container_info(container_name: str) -> Optional[Dict]:
'audio_only': container['audio_only'], """Return container info dict or None if not found."""
} for container in CONTAINER_INFO:
for container in CONTAINER_INFO if container['name'] == container_name:
} return container
return None
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Codec information # Codec information
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
CODEC_INFO = [ CODEC_INFO = [
# Audio codecs # Audio codecs
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'supports_transcoding': True, 'supports_video': False}, {'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'codec_type': 'audio'},
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'supports_transcoding': True, 'supports_video': False}, {'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'codec_type': 'audio'},
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'supports_transcoding': True, 'supports_video': False}, {'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'codec_type': 'audio'},
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False}, {'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'codec_type': 'audio'},
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'supports_transcoding': True, 'supports_video': False}, {'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'codec_type': 'audio'},
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False}, {'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'codec_type': 'audio'},
{'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'supports_transcoding': True, 'supports_video': False}, {'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'codec_type': 'audio'},
# Video codecs # Video codecs (including cover images)
{'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'supports_transcoding': True, 'supports_video': True}, {'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'codec_type': 'video'},
{'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True}, {'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'codec_type': 'video'},
{'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True}, {'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'codec_type': 'video'},
{'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'supports_transcoding': True, 'supports_video': True}, {'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'codec_type': 'video'},
{'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True}, {'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'codec_type': 'video'},
{'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True}, {'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'codec_type': 'video'},
{'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'supports_transcoding': False, 'supports_video': True}, {'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'codec_type': 'video'},
{'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'supports_transcoding': False, 'supports_video': True}, {'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'codec_type': 'video'},
# Subtitle codecs
{'name': 'srt', 'ffmpeg': 'srt', 'recommended_container': 'mkv', 'recommended_extension': '.srt', 'codec_type': 'subtitle'},
{'name': 'ass', 'ffmpeg': 'ass', 'recommended_container': 'mkv', 'recommended_extension': '.ass', 'codec_type': 'subtitle'},
{'name': 'vtt', 'ffmpeg': 'webvtt', 'recommended_container': 'webm', 'recommended_extension': '.vtt', 'codec_type': 'subtitle'},
] ]
# Map codec name → recommended container # Map codec name → recommended container
@@ -70,22 +76,6 @@ CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for code
# Map codec name → FFmpeg encoder name # Map codec name → FFmpeg encoder name
CODEC_NAME_TO_FFMPEG = {codec['name']: codec['ffmpeg'] for codec in CODEC_INFO} 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 # Compatibility matrix: container → supported audio and video codecs
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@@ -96,14 +86,13 @@ CONTAINER_VIDEO_CODEC_SUPPORT = {
# Use None to indicate that any video codec is supported. # Use None to indicate that any video codec is supported.
# Empty list means no video support (audio-only container). # Empty list means no video support (audio-only container).
COMPATIBILITY_MATRIX = { COMPATIBILITY_MATRIX = {
'mp3': {'audio': ['mp3'], 'video': []}, 'mp3': {'audio': ['mp3'], 'video': ['png', 'mjpeg']},
'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []}, 'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []},
'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']}, 'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']},
'mkv': {'audio': None, 'video': None}, 'mkv': {'audio': None, 'video': None},
'matroska': {'audio': None, 'video': None}, 'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8', 'png', 'mjpeg']},
'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8']},
'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']}, 'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']},
'flac': {'audio': ['flac'], 'video': []}, 'flac': {'audio': ['flac'], 'video': ['png', 'mjpeg']},
'wav': {'audio': ['pcm_s16le'], 'video': []}, 'wav': {'audio': ['pcm_s16le'], 'video': []},
'aac': {'audio': ['aac'], 'video': []}, 'aac': {'audio': ['aac'], 'video': []},
} }
@@ -132,4 +121,4 @@ def is_video_codec_supported(container: str, video_codec: str) -> bool:
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Default bad characters (for filename sanitization) # Default bad characters (for filename sanitization)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ' DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\'|," '
+72 -14
View File
@@ -7,9 +7,10 @@ import sys
from typing import List, Dict, Any from typing import List, Dict, Any
from .constants import ( from .constants import (
FORMAT_INFO, CONTAINER_INFO,
CONTAINER_NAMES,
CODEC_TO_EXTENSION_MAP, CODEC_TO_EXTENSION_MAP,
CODEC_NAME_TO_FFMPEG, # <-- Add this CODEC_NAME_TO_FFMPEG,
) )
from .ffmpeg import ( from .ffmpeg import (
get_audio_duration, get_audio_duration,
@@ -23,6 +24,7 @@ from .formats import (
determine_output_format, determine_output_format,
validate_format_compatibility, validate_format_compatibility,
) )
from .handlers import get_handler, needs_drop_video
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
@@ -58,7 +60,7 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
- merge_comments: bool - merge_comments: bool
- comment_separator: str - comment_separator: str
- delete_original: bool - delete_original: bool
- cover_image: str or None (optional, set by web backend) - cover_image: List[str] or None (optional, CLI list of cover image paths)
Raises: Raises:
RuntimeError: If no audio stream is found. RuntimeError: If no audio stream is found.
@@ -69,6 +71,17 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
os.makedirs(output_directory, exist_ok=True) os.makedirs(output_directory, exist_ok=True)
# Check write permission on output directory.
try:
test_file = os.path.join(output_directory, f'.write_test_{os.getpid()}')
with open(test_file, 'w') as f:
f.write('test')
os.remove(test_file)
except PermissionError:
raise RuntimeError(
f"Error: No write permission for output directory: {output_directory}"
)
total_duration = get_audio_duration(input_file) total_duration = get_audio_duration(input_file)
stream_info = get_stream_info(input_file) stream_info = get_stream_info(input_file)
@@ -116,11 +129,15 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
else: else:
output_audio_codec = stream_info.get('audio_codec', '') output_audio_codec = stream_info.get('audio_codec', '')
# Choose extension based on audio codec if possible, otherwise fallback to container default # Choose extension based on the output container, not the audio codec.
if output_audio_codec in CODEC_TO_EXTENSION_MAP: # When user specifies --container, the file extension must match the container.
container_info = next((c for c in CONTAINER_INFO if c['name'] == output_container), None)
if container_info:
extension = container_info['extension']
elif output_audio_codec in CODEC_TO_EXTENSION_MAP:
extension = CODEC_TO_EXTENSION_MAP[output_audio_codec] extension = CODEC_TO_EXTENSION_MAP[output_audio_codec]
else: else:
extension = FORMAT_INFO.get(output_container, {}).get('ext', '.mkv') extension = '.mkv'
print(f"Output extension: {extension}") print(f"Output extension: {extension}")
@@ -150,17 +167,34 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 7. Handle attached picture (cover art) # 7. Handle attached picture (cover art)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
cover_image_path = getattr(args, 'cover_image', None) user_cover_images = getattr(args, 'cover_image', None)
if cover_image_path and not os.path.exists(cover_image_path): # Resolve per-track cover images: single image reused, or one-per-track
cover_image_path = None track_cover_images = []
if user_cover_images:
if not cover_image_path and not args.drop_video and stream_info.get('has_video'): for img in user_cover_images:
if os.path.exists(img):
track_cover_images.append(img)
else:
print(f"Warning: Cover image not found, skipping: {img}")
# Extract cover from input if no user cover and input has video
if not track_cover_images and not args.drop_video and stream_info.get('has_video'):
if is_attached_picture(input_file): if is_attached_picture(input_file):
cover_image_path = os.path.join(output_directory, 'cover.png') cover_image_path = os.path.join(output_directory, 'cover.png')
if extract_cover_image(input_file, cover_image_path): if extract_cover_image(input_file, cover_image_path):
track_cover_images.append(cover_image_path)
print("Extracted cover image for all tracks.") print("Extracted cover image for all tracks.")
else: else:
cover_image_path = None track_cover_images = []
# Check if we need special handling (e.g., Opus files need opustags)
# Use output_audio_codec (not input_audio_codec) so the handler is resolved
# against the actual output codec (e.g., transcoding aac→opus in ogg).
input_audio_codec = stream_info.get('audio_codec')
cover_handler = get_handler(output_container, output_audio_codec)
needs_drop = needs_drop_video(output_container, output_audio_codec)
if track_cover_images and needs_drop:
print(f"Using special handler for {output_container} + {output_audio_codec}")
print("Temporarily dropping video for opustags post-processing.")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 8. Process each track # 8. Process each track
@@ -208,6 +242,15 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc) subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc)
# Then build command with these mapped encoders # Then build command with these mapped encoders
# Use temporary drop_video for handlers that need it
effective_drop_video = args.drop_video or (track_cover_images and needs_drop)
# For handlers that need opustags post-processing, don't pass cover_image_path
# to ffmpeg - it will process audio-only, then handler adds cover afterward
per_track_cover = (
track_cover_images[idx - 1] if len(track_cover_images) == len(tracks)
else (track_cover_images[0] if track_cover_images else None)
)
ffmpeg_cover_path = None if (per_track_cover and needs_drop) else per_track_cover
cmd = build_ffmpeg_command( cmd = build_ffmpeg_command(
input_file=input_file, input_file=input_file,
start_seconds=start_seconds, start_seconds=start_seconds,
@@ -219,9 +262,9 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
video_codec=video_enc, video_codec=video_enc,
subtitle_codec=subtitle_enc, subtitle_codec=subtitle_enc,
metadata=metadata, metadata=metadata,
cover_image_path=cover_image_path, cover_image_path=ffmpeg_cover_path,
video_quality=getattr(args, 'video_quality', None), video_quality=getattr(args, 'video_quality', None),
drop_video=args.drop_video, drop_video=effective_drop_video,
drop_subs=args.drop_subs, drop_subs=args.drop_subs,
) )
@@ -235,6 +278,21 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
print(result.stderr) print(result.stderr)
else: else:
print(f" -> Saved to: {output_path}") print(f" -> Saved to: {output_path}")
# Apply cover image handler if needed
if per_track_cover and needs_drop:
print(f"Applying cover image via {output_container} handler...")
if not cover_handler(
input_file=input_file,
cover_image_path=per_track_cover,
output_path=output_path,
stream_info=stream_info,
audio_codec=audio_enc,
video_codec=video_enc,
video_quality=getattr(args, 'video_quality', None),
drop_video=args.drop_video,
drop_subs=args.drop_subs,
):
print(f"Warning: Failed to attach cover image to {output_path}")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 9. Delete original file if requested # 9. Delete original file if requested
+90 -6
View File
@@ -2,10 +2,11 @@
"""FFmpeg/FFprobe interaction utilities.""" """FFmpeg/FFprobe interaction utilities."""
import json import json
import os
import subprocess import subprocess
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from .constants import FORMAT_INFO from .constants import CONTAINER_INFO
from .utils import format_time from .utils import format_time
@@ -159,7 +160,7 @@ def get_metadata(input_file: str) -> Dict[str, any]:
def get_container_format(input_file: str) -> Optional[str]: def get_container_format(input_file: str) -> Optional[str]:
""" """
Retrieve the container format name (e.g., 'mp4', 'mp3', 'matroska') from the input file. Retrieve the container format name (e.g., 'mp4', 'mp3', 'mkv') from the input file.
Args: Args:
input_file: Path to the media file. input_file: Path to the media file.
@@ -179,7 +180,7 @@ 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 # Normalize common aliases to names used in CONTAINER_INFO
mapping = { mapping = {
'mpeg': 'mp3', 'mpeg': 'mp3',
'mp2': 'mp3', 'mp2': 'mp3',
@@ -187,8 +188,8 @@ def get_container_format(input_file: str) -> Optional[str]:
'm4a': 'mp4', 'm4a': 'mp4',
'mov': 'mp4', 'mov': 'mp4',
'3gp': 'mp4', '3gp': 'mp4',
'matroska': 'matroska', 'matroska': 'mkv',
'webm': 'matroska', 'webm': 'mkv',
'ogg': 'ogg', 'ogg': 'ogg',
'flac': 'flac', 'flac': 'flac',
'wav': 'wav', 'wav': 'wav',
@@ -201,6 +202,86 @@ def get_container_format(input_file: str) -> Optional[str]:
return mapping.get(format_name, format_name) return mapping.get(format_name, format_name)
MAX_COVER_IMAGE_SIZE_MB = 4
def has_cover_or_video(input_file: str) -> bool:
"""
Check if the input file contains a cover image (attached picture) or any video track.
Returns:
True if a cover image or video stream is detected, False otherwise.
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-select_streams', 'v',
'-show_entries', 'stream=codec_type,codec_name,disposition',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return False
try:
data = json.loads(result.stdout)
streams = data.get('streams', [])
return len(streams) > 0
except (json.JSONDecodeError, KeyError):
return False
def validate_cover_images(cover_images: List[str], num_tracks: int) -> None:
"""
Validate cover image(s) before splitting.
Checks:
- Each file exists
- Only PNG and JPEG formats are accepted
- Single image OR count matches number of tracks
- File size warning for images > 4 MB
Args:
cover_images: List of cover image paths (single image or multiple).
num_tracks: Number of tracks in the tracklist.
Raises:
ValueError: If validation fails.
"""
if len(cover_images) == 0:
return
if len(cover_images) == 1 and num_tracks > 1:
# Single image is fine - will be reused for all tracks
pass
elif len(cover_images) == num_tracks:
pass
else:
raise ValueError(
f"Cover image count ({len(cover_images)}) must be 1 or match "
f"the number of tracks ({num_tracks})."
)
for img_path in cover_images:
if not os.path.exists(img_path):
raise ValueError(f"Cover image not found: {img_path}")
ext = os.path.splitext(img_path)[1].lower()
if ext not in ('.png', '.jpg', '.jpeg'):
raise ValueError(
f"Unsupported cover image format '{ext}' for '{img_path}'. "
f"Only PNG and JPEG are supported."
)
size_mb = os.path.getsize(img_path) / (1024 * 1024)
if size_mb > MAX_COVER_IMAGE_SIZE_MB:
print(
f"Warning: Cover image '{img_path}' is {size_mb:.1f} MB "
f"(exceeds {MAX_COVER_IMAGE_SIZE_MB} MB limit). "
f"Large images may cause issues."
)
def is_attached_picture(input_file: str) -> bool: def is_attached_picture(input_file: str) -> bool:
""" """
Check if the input file has a video stream that is an attached picture (cover art). Check if the input file has a video stream that is an attached picture (cover art).
@@ -310,6 +391,9 @@ def _build_cover_image_mapping(
else: else:
cmd.extend(['-c:a', 'copy']) cmd.extend(['-c:a', 'copy'])
# Preserve attached_pic disposition for cover image
cmd.extend(['-disposition', 'attached_pic'])
cmd.append('-sn') cmd.append('-sn')
if video_quality is not None: if video_quality is not None:
@@ -363,7 +447,7 @@ def _build_standard_mapping(
def _build_format(cmd: List[str], format_opt: Optional[str]) -> None: def _build_format(cmd: List[str], format_opt: Optional[str]) -> None:
"""Add output format option if specified.""" """Add output format option if specified."""
if format_opt: if format_opt:
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt) ffmpeg_format = next((c['ffmpeg'] for c in CONTAINER_INFO if c['name'] == format_opt), format_opt)
cmd.extend(['-f', ffmpeg_format]) cmd.extend(['-f', ffmpeg_format])
+12 -1
View File
@@ -41,7 +41,18 @@ def build_filename(track, idx, extension, args):
clean_filename = apply_replacement(raw_filename, clean_filename = apply_replacement(raw_filename,
args.bad_chars, args.bad_chars,
args.replacement_char) args.replacement_char)
clean_filename = cleanup_good_chars(clean_filename, args.replacement_char) # Split off the file extension before cleanup so trailing
# replacement chars don't leak into the name portion.
name_part, _, ext_part = clean_filename.rpartition('.')
clean_filename = cleanup_good_chars(name_part, args.replacement_char)
# Strip leading/trailing separator characters (replacement char,
# hyphen, etc.) that may result from empty fields or bad-char
# sequences adjacent to template separators.
clean_filename = clean_filename.strip(f'{args.replacement_char}-')
# Fallback: if cleanup leaves an empty name, use the track number.
if not clean_filename:
clean_filename = f"{idx:02d}"
clean_filename = f"{clean_filename}.{ext_part}"
else: else:
clean_filename = raw_filename clean_filename = raw_filename
# Warn about unsafe characters. # Warn about unsafe characters.
+24 -14
View File
@@ -4,7 +4,7 @@
from typing import Dict, Optional from typing import Dict, Optional
from .constants import ( from .constants import (
FORMAT_INFO, CONTAINER_NAMES,
CONTAINER_INFO, CONTAINER_INFO,
CODEC_TO_CONTAINER_MAP, CODEC_TO_CONTAINER_MAP,
COMPATIBILITY_MATRIX, COMPATIBILITY_MATRIX,
@@ -22,7 +22,7 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) ->
If the codec is not found, it falls back to the container. 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', 'mkv') as returned by get_container_format().
codec: Audio codec name (e.g., 'opus', 'aac', 'mp3') as returned by get_audio_codec(). codec: Audio codec name (e.g., 'opus', 'aac', 'mp3') as returned by get_audio_codec().
Returns: Returns:
@@ -33,10 +33,10 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) ->
if codec and codec in CODEC_TO_CONTAINER_MAP: if codec and codec in CODEC_TO_CONTAINER_MAP:
fmt = CODEC_TO_CONTAINER_MAP[codec] fmt = CODEC_TO_CONTAINER_MAP[codec]
if fmt in FORMAT_INFO: if fmt in CONTAINER_NAMES:
return fmt return fmt
if container in FORMAT_INFO: if container in CONTAINER_NAMES:
return container return container
return None return None
@@ -47,7 +47,7 @@ def determine_output_format(stream_info: Dict, user_format: Optional[str],
""" """
Decide which container format to use. Decide which container format to use.
If user_format is provided, use it. If user_format is provided, map it to the correct container if it's a codec name.
Else, try to detect the input file's container and codec, and use the recommended format. Else, try to detect the input file's container and codec, and use the recommended format.
If detection fails or format is not supported, fallback to: If detection fails or format is not supported, fallback to:
- MKV if video/subtitles exist - MKV if video/subtitles exist
@@ -55,6 +55,10 @@ def determine_output_format(stream_info: Dict, user_format: Optional[str],
- MP4 (M4A) otherwise - MP4 (M4A) otherwise
""" """
if user_format: if user_format:
# Map codec names to their correct containers
# e.g., 'opus' -> 'ogg', 'aac' -> 'mp4', etc.
if user_format in CODEC_TO_CONTAINER_MAP:
return CODEC_TO_CONTAINER_MAP[user_format]
return user_format return user_format
if input_file: if input_file:
@@ -63,14 +67,14 @@ def determine_output_format(stream_info: Dict, user_format: Optional[str],
container = get_container_format(input_file) container = get_container_format(input_file)
audio_codec = get_audio_codec(input_file) audio_codec = get_audio_codec(input_file)
fmt = determine_default_format(container, audio_codec) fmt = determine_default_format(container, audio_codec)
if fmt in FORMAT_INFO: if fmt in CONTAINER_NAMES:
return fmt return fmt
except Exception: except Exception:
pass pass
# Fallback # 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 'mkv'
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'
@@ -93,23 +97,25 @@ def validate_format_compatibility(
Raises: Raises:
ValueError: If the combination is incompatible. ValueError: If the combination is incompatible.
""" """
info = FORMAT_INFO.get(container) info = next((c for c in CONTAINER_INFO if c['name'] == container), None)
if not info: if not info:
print(f"Warning: Unknown container '{container}'. Proceeding, but may fail.") print(f"Warning: Unknown container '{container}'. Proceeding, but may fail.")
return return
# Check if container is audio-only and video is present (unless dropped) # 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: if not info['supports_video'] and stream_info.get('has_video') and not drop_video:
raise ValueError( raise ValueError(
f"Container '{container}' does not support video streams. " f"Container '{container}' does not support video streams. "
"Please use --drop-video or choose a container that supports video (e.g., MKV, MP4)." "Please use --drop-video or choose a container that supports video (e.g., MKV, MP4)."
) )
# Check if audio codec is supported # Determine actual audio codec for validation
if audio_codec and audio_codec != 'copy': # When 'copy' is used, we must still validate the input codec against the container
if not is_audio_codec_supported(container, audio_codec): actual_audio_codec = audio_codec if audio_codec and audio_codec != 'copy' else stream_info.get('audio_codec')
if actual_audio_codec:
if not is_audio_codec_supported(container, actual_audio_codec):
raise ValueError( raise ValueError(
f"Container '{container}' does not support audio codec '{audio_codec}'. " f"Container '{container}' does not support audio codec '{actual_audio_codec}'. "
f"Please choose a different container or audio codec." f"Please choose a different container or audio codec."
) )
@@ -119,7 +125,11 @@ def validate_format_compatibility(
if video_codec is None and input_file: if video_codec is None and input_file:
from .ffmpeg import get_video_codec from .ffmpeg import get_video_codec
video_codec = get_video_codec(input_file) video_codec = get_video_codec(input_file)
if video_codec and video_codec != 'copy': # When 'copy' is used, validate the input video codec against the container
if video_codec == 'copy' and input_file:
from .ffmpeg import get_video_codec
video_codec = get_video_codec(input_file)
if video_codec:
if not is_video_codec_supported(container, video_codec): if not is_video_codec_supported(container, video_codec):
raise ValueError( raise ValueError(
f"Container '{container}' does not support video codec '{video_codec}'. " f"Container '{container}' does not support video codec '{video_codec}'. "
+167
View File
@@ -0,0 +1,167 @@
# audio_splitter/handlers.py
"""Handler system for cover image attachment.
This module provides a registry of handlers for different
codec + image combinations. Each handler is responsible for
attaching the cover image to the output file in the appropriate way.
"""
import os
import subprocess
from typing import Dict, Optional
def _run_opustags(input_file: str, cover_image_path: str, output_path: Optional[str] = None) -> bool:
"""
Run opustags to attach a cover image to an Opus file.
Args:
input_file: Path to the input Opus file.
cover_image_path: Path to the cover image.
output_path: Optional output path (if None, overwrites input).
Returns:
True if successful, False otherwise.
"""
cmd = ['opustags', '--set-cover', cover_image_path, input_file]
if output_path:
cmd.extend(['-o', output_path, '-y'])
else:
cmd.extend(['-i'])
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"opustags failed: {result.stderr}")
return False
return True
def _handler_default(
input_file: str,
cover_image_path: str,
output_path: str,
stream_info: Dict,
audio_codec: str,
video_codec: str,
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> bool:
"""
Default handler: use ffmpeg with -disposition attached_pic.
This is the standard approach for formats like MP3, FLAC, etc.
"""
# This handler doesn't do anything - the cover is already handled
# by the ffmpeg command in core.py via build_ffmpeg_command()
# We just return True to indicate success.
return True
def _handler_opus_with_cover(
input_file: str,
cover_image_path: str,
output_path: str,
stream_info: Dict,
audio_codec: str,
video_codec: str,
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> bool:
"""
Handler for Opus files with cover image.
Uses opustags to attach the cover image after the file is processed.
"""
return _run_opustags(output_path, cover_image_path)
def _handler_mp3_with_cover(
input_file: str,
cover_image_path: str,
output_path: str,
stream_info: Dict,
audio_codec: str,
video_codec: str,
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> bool:
"""
Handler for MP3 files with cover image.
Uses ffmpeg with -disposition attached_pic.
"""
# MP3 handler - the cover is already attached by ffmpeg in core.py
return True
def _handler_flac_with_cover(
input_file: str,
cover_image_path: str,
output_path: str,
stream_info: Dict,
audio_codec: str,
video_codec: str,
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> bool:
"""
Handler for FLAC files with cover image.
Uses ffmpeg with -disposition attached_pic.
"""
# FLAC handler - the cover is already attached by ffmpeg in core.py
return True
# Handler registry: maps (output_container, input_codec) to handler function
HANDLER_REGISTRY: Dict[tuple, callable] = {
('opus', 'opus'): _handler_opus_with_cover,
('ogg', 'opus'): _handler_opus_with_cover,
('mp3', 'mp3'): _handler_mp3_with_cover,
('flac', 'flac'): _handler_flac_with_cover,
}
def get_handler(output_container: str, input_audio_codec: Optional[str] = None):
"""
Get the appropriate handler for the given container and input codec.
Args:
output_container: Output container format (e.g., 'opus', 'mp3', 'flac').
input_audio_codec: Input audio codec (e.g., 'opus', 'mp3', 'flac').
Returns:
Handler function, or the default handler if no specific handler is found.
"""
if input_audio_codec:
key = (output_container, input_audio_codec)
if key in HANDLER_REGISTRY:
return HANDLER_REGISTRY[key]
# Fall back to container-only key
if (output_container, None) in HANDLER_REGISTRY:
return HANDLER_REGISTRY[(output_container, None)]
# Default handler
return _handler_default
def needs_drop_video(output_container: str, input_audio_codec: Optional[str] = None) -> bool:
"""
Check if the handler requires dropping video even if user didn't specify --drop-video.
Args:
output_container: Output container format.
input_audio_codec: Input audio codec.
Returns:
True if video should be dropped, False otherwise.
"""
handler = get_handler(output_container, input_audio_codec)
# The opus handler requires dropping video
return handler == _handler_opus_with_cover
+33
View File
@@ -32,8 +32,10 @@ from .defaults import (
DEFAULT_SKIP_EXISTING, DEFAULT_SKIP_EXISTING,
DEFAULT_DELETE_ORIGINAL, DEFAULT_DELETE_ORIGINAL,
) )
from .constants import CONTAINER_NAMES
from .core import split_audio from .core import split_audio
from .tracklist import read_tracklist, parse_format from .tracklist import read_tracklist, parse_format
from .ffmpeg import has_cover_or_video, validate_cover_images
def main(): def main():
@@ -156,6 +158,16 @@ def main():
help="Tracklist format (default: %(default)s)" help="Tracklist format (default: %(default)s)"
) )
# Cover image
parser.add_argument(
'--cover-image',
nargs='+',
metavar='IMAGE',
default=None,
help="Cover image path(s). Single image applied to all tracks, "
"or one per track (must match track count)."
)
# Other # Other
parser.add_argument( parser.add_argument(
'--delete-original', '--delete-original',
@@ -185,6 +197,12 @@ 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)
# Validate container if provided
if args.container and args.container not in CONTAINER_NAMES:
print(f"Error: Invalid container '{args.container}'. "
f"Valid containers: {', '.join(sorted(CONTAINER_NAMES))}.")
sys.exit(1)
# Check FFmpeg # Check FFmpeg
try: try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True) subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
@@ -208,6 +226,21 @@ def main():
print(f"Found {len(tracks)} tracks.") print(f"Found {len(tracks)} tracks.")
# Validate cover images if provided
if args.cover_image:
# Check if input already has cover image or video track
# Allow if --drop-video is specified (user wants to discard existing video)
if has_cover_or_video(args.input_file) and not args.drop_video:
print("Error: Input file already contains a cover image or video track. "
"Remove it first, or use --drop-video to discard video streams.")
sys.exit(1)
try:
validate_cover_images(args.cover_image, len(tracks))
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
if args.dry_run: if args.dry_run:
print("\nParsed tracklist:") print("\nParsed tracklist:")
print("-" * 60) print("-" * 60)
+11
View File
@@ -73,4 +73,15 @@ def resolve_end_times(track_times, total_duration):
if end_sec <= start_sec: if end_sec <= start_sec:
raise ValueError(f"Track {idx+1}: end time ({end_sec}) is not after start ({start_sec})") raise ValueError(f"Track {idx+1}: end time ({end_sec}) is not after start ({start_sec})")
resolved.append((start_sec, end_sec)) resolved.append((start_sec, end_sec))
# Warn about overlapping intervals
for idx in range(len(resolved) - 1):
cur_end = resolved[idx][1]
next_start = resolved[idx + 1][0]
if cur_end > next_start:
print(
f"Warning: Track {idx+1} ends at {cur_end}s but Track {idx+2} starts at "
f"{next_start}s — timestamps overlap by {cur_end - next_start:.1f}s."
)
return resolved return resolved
+2 -1
View File
@@ -1,9 +1,10 @@
FROM python:3.13-slim FROM python:3.13-slim
# Install FFmpeg, system dependencies, and gosu from APT # Install FFmpeg, opustags, system dependencies, and gosu from APT
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
opustags \
ca-certificates \ ca-certificates \
gosu \ gosu \
&& \ && \
+3 -2
View File
@@ -3,7 +3,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from audio_splitter.constants import CONTAINER_INFO, CODEC_INFO from audio_splitter.constants import CONTAINER_INFO, CODEC_INFO, COMPATIBILITY_MATRIX
router = APIRouter(prefix="/api", tags=["formats"]) router = APIRouter(prefix="/api", tags=["formats"])
@@ -11,9 +11,10 @@ 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 containers and codecs. Return the list of supported containers, codecs, and compatibility matrix.
""" """
return { return {
"containers": CONTAINER_INFO, "containers": CONTAINER_INFO,
"codecs": CODEC_INFO, "codecs": CODEC_INFO,
"compatibility": COMPATIBILITY_MATRIX,
} }
+2 -2
View File
@@ -9,7 +9,7 @@ from backend.services.task_manager import task_manager
# Import core functions # Import core functions
from audio_splitter.ffmpeg import get_stream_info, get_container_format, get_audio_codec from audio_splitter.ffmpeg import get_stream_info, get_container_format, get_audio_codec
from audio_splitter.formats import determine_default_format from audio_splitter.formats import determine_default_format
from audio_splitter.constants import FORMAT_INFO from audio_splitter.constants import CONTAINER_NAMES
from audio_splitter.defaults import DEFAULT_FORMAT from audio_splitter.defaults import DEFAULT_FORMAT
router = APIRouter(prefix="/api", tags=["info"]) router = APIRouter(prefix="/api", tags=["info"])
@@ -63,7 +63,7 @@ async def get_recommended_format(task_id: str):
# Fallback if detection fails or format is unsupported # Fallback if detection fails or format is unsupported
if fmt is None: if fmt is None:
fmt = DEFAULT_FORMAT fmt = DEFAULT_FORMAT
if fmt not in FORMAT_INFO: if fmt not in CONTAINER_NAMES:
fmt = "mp3" # ultimate fallback fmt = "mp3" # ultimate fallback
return {"format": fmt} return {"format": fmt}
+2 -1
View File
@@ -14,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["upload"])
ALLOWED_EXTENSIONS = { ALLOWED_EXTENSIONS = {
".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus", ".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus",
".aac", ".wma", ".aiff", ".alac", ".ac3" ".aac", ".wma", ".aiff", ".alac", ".ac3",
".mp4", ".mkv", ".webm",
} }
-12
View File
@@ -1,12 +0,0 @@
FORMAT_INFO = {
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
}
+3 -15
View File
@@ -70,21 +70,9 @@ 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
# Detect attached picture and extract if applicable # Cover image handling is now done internally by split_audio()
cover_image_path = None # via the handler system (opustags for Opus, ffmpeg for others)
if not args.drop_video: args.cover_image = None
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_manager.update_task_with_progress(
task_id, progress=10, message="Starting split..." task_id, progress=10, message="Starting split..."
+2 -2
View File
@@ -7,7 +7,7 @@ import { useOptionsStore } from '../stores/optionsStore' // NEW
import { uploadFile, getTaskInfo, getRecommendedFormat } from '../api/client' // NEW import { uploadFile, getTaskInfo, getRecommendedFormat } from '../api/client' // NEW
import { useTaskStore } from '../stores/taskStore' import { useTaskStore } from '../stores/taskStore'
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3'] const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3', '.mp4', '.mkv', '.webm']
export const UploadZone: React.FC = () => { export const UploadZone: React.FC = () => {
const { const {
@@ -96,7 +96,7 @@ export const UploadZone: React.FC = () => {
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop, onDrop,
accept: { accept: {
'audio/*': ALLOWED_EXTENSIONS, 'media/*': ALLOWED_EXTENSIONS
}, },
multiple: false, multiple: false,
disabled: isUploading, disabled: isUploading,
+6 -6
View File
@@ -13,18 +13,18 @@ export function useFilterCodecs(
) { ) {
const filteredAudioCodecs = useMemo(() => { const filteredAudioCodecs = useMemo(() => {
const entry = compatibility?.[container || ''] const entry = compatibility?.[container || '']
if (!entry) return codecs.filter(c => c.supports_transcoding) if (!entry) return codecs.filter(c => c.codec_type === 'audio')
const audioList = entry.audio const audioList = entry.audio
if (audioList === null) return codecs.filter(c => c.supports_transcoding) if (audioList === null) return codecs.filter(c => c.codec_type === 'audio')
return codecs.filter(c => c.supports_transcoding && audioList.includes(c.name)) return codecs.filter(c => c.codec_type === 'audio' && audioList.includes(c.name))
}, [compatibility, container, codecs]) }, [compatibility, container, codecs])
const filteredVideoCodecs = useMemo(() => { const filteredVideoCodecs = useMemo(() => {
const entry = compatibility?.[container || ''] const entry = compatibility?.[container || '']
if (!entry) return codecs.filter(c => c.supports_video) if (!entry) return codecs.filter(c => c.codec_type === 'video')
const videoList = entry.video const videoList = entry.video
if (videoList === null) return codecs.filter(c => c.supports_video) if (videoList === null) return codecs.filter(c => c.codec_type === 'video')
return codecs.filter(c => c.supports_video && videoList.includes(c.name)) return codecs.filter(c => c.codec_type === 'video' && videoList.includes(c.name))
}, [compatibility, container, codecs]) }, [compatibility, container, codecs])
return { filteredAudioCodecs, filteredVideoCodecs } return { filteredAudioCodecs, filteredVideoCodecs }
@@ -16,7 +16,7 @@ export function useFormatValidation(
) { ) {
useEffect(() => { useEffect(() => {
const selectedContainer = containers.find(c => c.name === options.container) const selectedContainer = containers.find(c => c.name === options.container)
if (selectedContainer?.audio_only && hasVideo && !options.drop_video) { if (!selectedContainer?.supports_video && hasVideo && !options.drop_video) {
setFormatError( setFormatError(
`Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.` `Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.`
) )
+1 -3
View File
@@ -48,7 +48,6 @@ export interface ContainerInfo {
name: string name: string
ffmpeg: string ffmpeg: string
extension: string extension: string
audio_only: boolean
supports_video: boolean supports_video: boolean
supports_subs: boolean supports_subs: boolean
} }
@@ -58,8 +57,7 @@ export interface CodecInfo {
ffmpeg: string ffmpeg: string
recommended_container: string recommended_container: string
recommended_extension: string recommended_extension: string
supports_transcoding: boolean codec_type: 'audio' | 'video' | 'subtitle'
supports_video: boolean
} }
export interface CompatibilityEntry { export interface CompatibilityEntry {