37 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
max 5e02184c8f REFACTOR (FRONTEND): exports some functionality from web/frontend/src/components/OptionsPanel.tsx into separated hooks 2026-08-30 15:26:02 +05:00
max e935241fd8 REFACTOR (CORE): splits build_ffmpeg_command method into one orchestrator and helper methods 2026-08-30 14:47:13 +05:00
max 8e0d345ba7 FIX: maps codecs names and ffmpeg encoders correctly 2026-08-28 15:41:21 +05:00
max 3533525df0 FEATURE: propagate improvements from CLI and web-API versions to web-frontend 2026-08-28 15:40:38 +05:00
max 834239674a FEATURE: propogate improvements from CLI version to web-backend 2026-08-28 13:24:46 +05:00
max 748532be7c FIX: codec/container/file_extension autodetection when default values are used 2026-08-28 11:04:40 +05:00
max f3abbb8f0c FEATURE: adds quality flag for video stream 2026-08-25 17:00:43 +05:00
max 690c5f98c6 FEATURE: adds strict codec/container/file_extension verification, renames ambiguous options, adds flexible transcoding 2026-08-25 10:40:48 +05:00
max 262d458ce4 FEATURE: codecs/containers/file_extensions inconsistency fixed 2026-08-21 15:59:05 +05:00
max 7af2600ed8 FEATURE: all versions use single source of truth for codec/container/file_extension info 2026-08-21 14:58:55 +05:00
max d08045bf02 Merge pull request 'fix(frontend): frontend functional bugs' (#9) from frontend_bugs into dev
Reviewed-on: #9
2026-08-21 13:35:17 +04:00
max 4843ac1cb5 FEATURE: clarifes 'format' option in output section of the frontend 2026-08-20 20:21:15 +05:00
max 364fc7a8fe FEATURE: adds automatic format detected to the frontend 2026-08-20 19:59:11 +05:00
max 8a1e2a5335 FIX: aligns line number with the corresponding lines in the tracklist editor 2026-08-20 12:47:58 +05:00
max 3c7ff97e76 Merge pull request 'feat(core): single source of truth for default values added' (#6) from default_args into dev
Reviewed-on: #6
2026-08-20 09:34:21 +04:00
max 140a0fbc46 FEATURE: single source of truth for default values added 2026-08-20 10:28:07 +05:00
max 05fcc8ea29 Merge pull request 'FEAT(backend): allows the backend to take a tracklis text file' (#5) from backend_tracks_file into dev
Reviewed-on: #5
2026-08-19 16:30:10 +04:00
max 52d01f1bcd FEATURE: allows the backend to take a tracklis text file 2026-08-19 17:26:07 +05:00
max 607836b280 CHANGE: drops cli version docker support 2026-08-19 13:12:34 +05:00
37 changed files with 1895 additions and 565 deletions
-57
View File
@@ -1,57 +0,0 @@
FROM python:3.13-slim
# Install FFmpeg and dependencies required for gosu installation
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
wget \
gnupg \
dirmngr \
gnupg-agent && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install gosu (lightweight tool for dropping privileges)
RUN set -eux; \
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch"; \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch.asc"; \
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
chmod +x /usr/local/bin/gosu; \
gosu --version
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set working directory
WORKDIR /app
# Copy package metadata and source code
COPY setup.py pyproject.toml README.md ./
COPY audio_splitter/ ./audio_splitter/
# Install the package
RUN pip install --no-cache-dir .
# Create a non-root user with UID 1000
RUN addgroup --system --gid 1000 appgroup && \
adduser --system --uid 1000 --ingroup appgroup appuser
# Change ownership of /app to the container user (so it can write there if needed)
RUN chown -R appuser:appgroup /app
# Copy the entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set the entrypoint
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# Default command (shows help if no arguments provided)
CMD ["--help"]
+121 -17
View File
@@ -1,20 +1,124 @@
"""Global constants and default values.""" # audio_splitter/constants.py
"""Global constants for the audio splitter.
# Mapping from userfriendly format names to FFmpeg format identifiers, This file serves as the single source of truth for:
# file extensions, and whether the container is audioonly. - Container formats and their properties
FORMAT_INFO = { - Audio codecs and their recommended containers
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True}, - Video codec support per container
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True}, - File extensions for each codec/container combination
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False}, - Compatibility matrix for codec/container validation
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False}, Codec != Container != File Extension.
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True}, Example: Opus (codec) → Ogg (container) → .opus (extension)
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True}, """
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True}, from typing import Dict, Optional
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True}, # ------------------------------------------------------------------------------
# Container information
# ------------------------------------------------------------------------------
CONTAINER_INFO = [
# Audio-only containers
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'supports_video': True, 'supports_subs': False},
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'supports_video': False, 'supports_subs': False},
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'supports_video': False, 'supports_subs': False},
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'supports_video': False, 'supports_subs': False},
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'supports_video': False, 'supports_subs': False},
# Containers that support video and subtitles
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'supports_video': True, 'supports_subs': True},
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'supports_video': True, 'supports_subs': True},
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'supports_video': True, 'supports_subs': True},
{'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'supports_video': True, 'supports_subs': False},
]
# Container name lookup set for fast membership checks
CONTAINER_NAMES = {container['name'] for container in CONTAINER_INFO}
def get_container_info(container_name: str) -> Optional[Dict]:
"""Return container info dict or None if not found."""
for container in CONTAINER_INFO:
if container['name'] == container_name:
return container
return None
# ------------------------------------------------------------------------------
# Codec information
# ------------------------------------------------------------------------------
CODEC_INFO = [
# Audio codecs
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'codec_type': 'audio'},
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'codec_type': 'audio'},
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'codec_type': 'audio'},
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'codec_type': 'audio'},
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'codec_type': 'audio'},
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'codec_type': 'audio'},
{'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'codec_type': 'audio'},
# Video codecs (including cover images)
{'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'codec_type': 'video'},
{'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'codec_type': 'video'},
{'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'codec_type': 'video'},
{'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'codec_type': 'video'},
{'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'codec_type': 'video'},
{'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'codec_type': 'video'},
{'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'codec_type': 'video'},
{'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'codec_type': 'video'},
# Subtitle codecs
{'name': 'srt', 'ffmpeg': 'srt', 'recommended_container': 'mkv', 'recommended_extension': '.srt', 'codec_type': 'subtitle'},
{'name': 'ass', 'ffmpeg': 'ass', 'recommended_container': 'mkv', 'recommended_extension': '.ass', 'codec_type': 'subtitle'},
{'name': 'vtt', 'ffmpeg': 'webvtt', 'recommended_container': 'webm', 'recommended_extension': '.vtt', 'codec_type': 'subtitle'},
]
# Map codec name → recommended container
CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO}
CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO}
# Map codec name → FFmpeg encoder name
CODEC_NAME_TO_FFMPEG = {codec['name']: codec['ffmpeg'] for codec in CODEC_INFO}
# ------------------------------------------------------------------------------
# Compatibility matrix: container → supported audio and video codecs
# ------------------------------------------------------------------------------
# Each container entry maps to a dict with 'audio' and 'video' keys.
# - 'audio': list of audio codec names that are supported in this container.
# Use None to indicate that any audio codec is supported.
# - 'video': list of video codec names that are supported in this container.
# Use None to indicate that any video codec is supported.
# Empty list means no video support (audio-only container).
COMPATIBILITY_MATRIX = {
'mp3': {'audio': ['mp3'], 'video': ['png', 'mjpeg']},
'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []},
'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']},
'mkv': {'audio': None, 'video': None},
'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8', 'png', 'mjpeg']},
'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']},
'flac': {'audio': ['flac'], 'video': ['png', 'mjpeg']},
'wav': {'audio': ['pcm_s16le'], 'video': []},
'aac': {'audio': ['aac'], 'video': []},
} }
# Default characters to replace when --replace-bad-chars is enabled. # ------------------------------------------------------------------------------
# Includes common punctuation, single quote, and a trailing space. # Helper functions for compatibility checking
DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' ' # ------------------------------------------------------------------------------
def is_audio_codec_supported(container: str, audio_codec: str) -> bool:
"""Check if an audio codec is supported in the given container."""
entry = COMPATIBILITY_MATRIX.get(container, {})
supported = entry.get('audio')
if supported is None:
return True
return audio_codec in supported
def is_video_codec_supported(container: str, video_codec: str) -> bool:
"""Check if a video codec is supported in the given container."""
entry = COMPATIBILITY_MATRIX.get(container, {})
supported = entry.get('video')
if supported is None:
return True
return video_codec in supported
# ------------------------------------------------------------------------------
# Default bad characters (for filename sanitization)
# ------------------------------------------------------------------------------
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\'|," '
+204 -29
View File
@@ -1,36 +1,87 @@
"""Core logic: orchestrates the splitting process.""" # audio_splitter/core.py
"""Core splitting logic orchestrates the entire split process."""
import os import os
import subprocess import subprocess
import sys
from typing import List, Dict, Any
from .constants import FORMAT_INFO from .constants import (
from .ffmpeg import get_audio_duration, get_stream_info, get_metadata, build_ffmpeg_command CONTAINER_INFO,
from .formats import determine_output_format, validate_format_compatibility CONTAINER_NAMES,
CODEC_TO_EXTENSION_MAP,
CODEC_NAME_TO_FFMPEG,
)
from .ffmpeg import (
get_audio_duration,
get_stream_info,
get_metadata,
build_ffmpeg_command,
is_attached_picture,
extract_cover_image,
)
from .formats import (
determine_output_format,
validate_format_compatibility,
)
from .handlers import get_handler, needs_drop_video
from .timestamp import parse_track_timestamps, resolve_end_times from .timestamp import parse_track_timestamps, resolve_end_times
from .filename import build_filename from .filename import build_filename
from .metadata import build_metadata_dict from .metadata import build_metadata_dict
from .utils import format_time from .utils import format_time
def split_audio(input_file, output_directory, tracks, args): def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, Any]], args) -> None:
""" """
Main orchestration function: split the audio file into tracks. Main orchestration function: split the audio file into tracks.
Args: Args:
input_file: Path to the input media file. input_file: Path to the input media file.
output_directory: Directory where output files will be saved. output_directory: Directory where output files will be saved.
tracks: List of dicts, each containing parsed fields. tracks: List of dicts, each containing parsed fields (ts, tn, an, ...).
args: Parsed commandline arguments (namespace). args: Parsed commandline arguments (namespace) with attributes:
- container: output container name (or None for auto-detect)
- audio_codec: audio codec (copy or encoder)
- video_codec: video codec (copy or encoder)
- subtitle_codec: subtitle codec (copy or encoder)
- video_quality: integer or None
- drop_video: bool
- drop_subs: bool
- number_tracks: bool
- output_template: str
- replace_bad_chars: bool
- replacement_char: str
- bad_chars: str
- skip_existing: bool
- album: str or None
- comment: str or None
- no_comment: bool
- comment_stream: int or None
- merge_comments: bool
- comment_separator: str
- delete_original: bool
- cover_image: 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.
ValueError: If timestamp parsing or format compatibility fails. ValueError: If compatibility validation fails.
""" """
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 1. Setup # 1. Setup
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
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)
@@ -42,26 +93,62 @@ def split_audio(input_file, output_directory, tracks, args):
raise RuntimeError("No audio stream found in input file.") raise RuntimeError("No audio stream found in input file.")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 2. Format decision # 2. Determine output container
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
output_format = determine_output_format(stream_info, args.format, args.transcode_to, input_file=input_file) user_container = getattr(args, 'container', None)
print(f"Output container: {output_format}") output_container = determine_output_format(
stream_info,
validate_format_compatibility(output_format, stream_info, user_format=user_container,
args.drop_video, args.drop_subs) transcode_audio=args.audio_codec if args.audio_codec != 'copy' else None,
input_file=input_file
# Determine file extension. )
extension_info = FORMAT_INFO.get(output_format, {}) print(f"Output container: {output_container}")
extension = extension_info.get('ext', '.mkv')
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 3. Parse timestamps # 3. Validate compatibility
# --------------------------------------------------------------------------
try:
validate_format_compatibility(
container=output_container,
stream_info=stream_info,
drop_video=args.drop_video,
drop_subs=args.drop_subs,
input_file=input_file,
audio_codec=args.audio_codec,
video_codec=args.video_codec,
)
except ValueError as e:
raise RuntimeError(f"Compatibility error: {e}")
# --------------------------------------------------------------------------
# 4. Determine output audio codec and extension
# --------------------------------------------------------------------------
# Determine the audio codec that will be used in the output
if args.audio_codec != 'copy':
output_audio_codec = args.audio_codec
else:
output_audio_codec = stream_info.get('audio_codec', '')
# Choose extension based on the output container, not the audio codec.
# When user specifies --container, the file extension must match the container.
container_info = next((c for c in CONTAINER_INFO if c['name'] == output_container), None)
if container_info:
extension = container_info['extension']
elif output_audio_codec in CODEC_TO_EXTENSION_MAP:
extension = CODEC_TO_EXTENSION_MAP[output_audio_codec]
else:
extension = '.mkv'
print(f"Output extension: {extension}")
# --------------------------------------------------------------------------
# 5. Parse timestamps
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
track_times = parse_track_timestamps(tracks) track_times = parse_track_timestamps(tracks)
resolved_times = resolve_end_times(track_times, total_duration) resolved_times = resolve_end_times(track_times, total_duration)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 4. Fetch original metadata (for fallbacks) # 6. Fetch original metadata (for fallbacks)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
input_metadata = get_metadata(input_file) input_metadata = get_metadata(input_file)
original_album = input_metadata.get('album') original_album = input_metadata.get('album')
@@ -78,7 +165,39 @@ def split_audio(input_file, output_directory, tracks, args):
print(f" Stream {idx}: '{comment}'") print(f" Stream {idx}: '{comment}'")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 5. Process each track # 7. Handle attached picture (cover art)
# --------------------------------------------------------------------------
user_cover_images = getattr(args, 'cover_image', None)
# Resolve per-track cover images: single image reused, or one-per-track
track_cover_images = []
if user_cover_images:
for img in user_cover_images:
if os.path.exists(img):
track_cover_images.append(img)
else:
print(f"Warning: Cover image not found, skipping: {img}")
# Extract cover from input if no user cover and input has video
if not track_cover_images and not args.drop_video and stream_info.get('has_video'):
if is_attached_picture(input_file):
cover_image_path = os.path.join(output_directory, 'cover.png')
if extract_cover_image(input_file, cover_image_path):
track_cover_images.append(cover_image_path)
print("Extracted cover image for all tracks.")
else:
track_cover_images = []
# Check if we need special handling (e.g., Opus files need opustags)
# Use output_audio_codec (not input_audio_codec) so the handler is resolved
# against the actual output codec (e.g., transcoding aac→opus in ogg).
input_audio_codec = stream_info.get('audio_codec')
cover_handler = get_handler(output_container, output_audio_codec)
needs_drop = needs_drop_video(output_container, output_audio_codec)
if track_cover_images and needs_drop:
print(f"Using special handler for {output_container} + {output_audio_codec}")
print("Temporarily dropping video for opustags post-processing.")
# --------------------------------------------------------------------------
# 8. Process each track
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
for idx, track in enumerate(tracks, start=1): for idx, track in enumerate(tracks, start=1):
start_seconds, end_seconds = resolved_times[idx - 1] start_seconds, end_seconds = resolved_times[idx - 1]
@@ -91,31 +210,62 @@ def split_audio(input_file, output_directory, tracks, args):
track_name = track.get('tn', 'Unknown') track_name = track.get('tn', 'Unknown')
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5a. Build filename # 8a. Build filename
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
clean_filename = build_filename(track, idx, extension, args) clean_filename = build_filename(track, idx, extension, args)
output_path = os.path.join(output_directory, clean_filename) output_path = os.path.join(output_directory, clean_filename)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5b. Handle existing files # 8b. Handle existing files
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
if args.skip_existing and os.path.exists(output_path): if args.skip_existing and os.path.exists(output_path):
print(f"Skipping track {idx}: {output_path} already exists.") print(f"Skipping track {idx}: {output_path} already exists.")
continue continue
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5c. Build metadata # 8c. Build metadata
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
metadata = build_metadata_dict(track, idx, input_metadata, args) metadata = build_metadata_dict(track, idx, input_metadata, args)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5d. Build and execute FFmpeg command # 8d. Build and execute FFmpeg command
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Map codec names to FFmpeg encoder names
audio_enc = args.audio_codec
if audio_enc != 'copy':
audio_enc = CODEC_NAME_TO_FFMPEG.get(audio_enc, audio_enc) # fallback to itself if not found
video_enc = args.video_codec
if video_enc != 'copy':
video_enc = CODEC_NAME_TO_FFMPEG.get(video_enc, video_enc)
subtitle_enc = args.subtitle_codec
if subtitle_enc != 'copy':
subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc)
# Then build command with these mapped encoders
# 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, start_seconds, duration_seconds, output_path, input_file=input_file,
stream_info, output_format, args.transcode_to, start_seconds=start_seconds,
args.drop_video, args.drop_subs, duration_seconds=duration_seconds,
metadata=metadata output_path=output_path,
stream_info=stream_info,
format_opt=output_container,
audio_codec=audio_enc,
video_codec=video_enc,
subtitle_codec=subtitle_enc,
metadata=metadata,
cover_image_path=ffmpeg_cover_path,
video_quality=getattr(args, 'video_quality', None),
drop_video=effective_drop_video,
drop_subs=args.drop_subs,
) )
print(f"Extracting track {idx}: {track_name} " print(f"Extracting track {idx}: {track_name} "
@@ -128,3 +278,28 @@ def split_audio(input_file, output_directory, tracks, args):
print(result.stderr) print(result.stderr)
else: else:
print(f" -> Saved to: {output_path}") print(f" -> Saved to: {output_path}")
# Apply cover image handler if needed
if per_track_cover and needs_drop:
print(f"Applying cover image via {output_container} handler...")
if not cover_handler(
input_file=input_file,
cover_image_path=per_track_cover,
output_path=output_path,
stream_info=stream_info,
audio_codec=audio_enc,
video_codec=video_enc,
video_quality=getattr(args, 'video_quality', None),
drop_video=args.drop_video,
drop_subs=args.drop_subs,
):
print(f"Warning: Failed to attach cover image to {output_path}")
# --------------------------------------------------------------------------
# 9. Delete original file if requested
# --------------------------------------------------------------------------
if getattr(args, 'delete_original', False):
try:
os.remove(input_file)
print(f"Deleted original file: {input_file}")
except OSError as e:
print(f"Warning: Could not delete original file: {e}")
+35
View File
@@ -0,0 +1,35 @@
"""Default values for all configurable options shared between CLI, web backend, and frontend."""
# Output container format (used when user doesn't specify one; auto-detection overrides this)
DEFAULT_FORMAT = "mp3"
# Filename template
DEFAULT_OUTPUT_TEMPLATE = "%an-%tn.%ext"
# Character replacement
DEFAULT_REPLACEMENT_CHAR = "_"
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
# Tracklist parsing
DEFAULT_TRACKLIST_FORMAT = "%ts %tn - %an"
# Metadata defaults
DEFAULT_ALBUM = None
DEFAULT_COMMENT = None
DEFAULT_COMMENT_STREAM = None
DEFAULT_COMMENT_SEPARATOR = "; "
DEFAULT_NO_COMMENT = False
DEFAULT_MERGE_COMMENTS = False
# Stream handling
DEFAULT_DROP_VIDEO = False
DEFAULT_DROP_SUBS = False
# Filename/export options
DEFAULT_NUMBER_TRACKS = False
DEFAULT_REPLACE_BAD_CHARS = True
DEFAULT_SKIP_EXISTING = False
DEFAULT_DELETE_ORIGINAL = False
# Transcoding (None means copy codec)
DEFAULT_TRANSCODE_TO = None
+310 -53
View File
@@ -1,10 +1,12 @@
"""FFmpeg/FFprobe interaction utilities for the CLI and web backend.""" # audio_splitter/ffmpeg.py
"""FFmpeg/FFprobe interaction utilities."""
import json import 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
@@ -75,6 +77,28 @@ def get_audio_codec(input_file: str) -> Optional[str]:
return codec if codec else None return codec if codec else None
def get_video_codec(input_file: str) -> Optional[str]:
"""
Return the codec name of the first video stream.
Args:
input_file: Path to the media file.
Returns:
Codec name as a lowercase string, or None if no video stream exists.
"""
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
codec = result.stdout.strip().lower()
return codec if codec else None
def get_stream_info(input_file: str) -> Dict[str, any]: def get_stream_info(input_file: str) -> Dict[str, any]:
""" """
Collect information about the streams present in the input file. Collect information about the streams present in the input file.
@@ -136,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.
@@ -156,69 +180,237 @@ def get_container_format(input_file: str) -> Optional[str]:
format_name = result.stdout.strip().split(',')[0] # take first if multiple format_name = result.stdout.strip().split(',')[0] # take first if multiple
if not format_name: if not format_name:
return None return None
# Normalize common aliases to names used in FORMAT_INFO (container only, not codec-specific) # Normalize common aliases to names used in CONTAINER_INFO
# This mapping is purely for container identification.
mapping = { mapping = {
'mpeg': 'mp3', # MPEG-1/2 audio (MP3) container 'mpeg': 'mp3',
'mp2': 'mp3', 'mp2': 'mp3',
'mp4': 'mp4', 'mp4': 'mp4',
'm4a': 'mp4', # M4A is MP4 container 'm4a': 'mp4',
'mov': 'mp4', # QuickTime is MP4-like 'mov': 'mp4',
'3gp': 'mp4', '3gp': 'mp4',
'matroska': 'matroska', 'matroska': 'mkv',
'webm': 'matroska', # WebM uses Matroska container 'webm': 'mkv',
'ogg': 'ogg', 'ogg': 'ogg',
'flac': 'flac', 'flac': 'flac',
'wav': 'wav', 'wav': 'wav',
'aac': 'aac', 'aac': 'aac',
'opus': 'opus', 'opus': 'opus',
'mp3': 'mp3', 'mp3': 'mp3',
'adts': 'aac', # raw AAC in ADTS container 'adts': 'aac',
'amr': 'amr', # AMR container (rare) 'amr': 'amr',
} }
return mapping.get(format_name, format_name) return mapping.get(format_name, format_name)
def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: int, MAX_COVER_IMAGE_SIZE_MB = 4
output_path: str, stream_info: Dict, format_opt: Optional[str],
transcode_audio: Optional[str], drop_video: bool, drop_subs: bool,
metadata: Optional[Dict] = None) -> List[str]:
"""
Construct the FFmpeg command line as a list of arguments.
Args:
input_file: Path to the input media file. def has_cover_or_video(input_file: str) -> bool:
start_seconds: Start time for the segment (in seconds). """
duration_seconds: Duration of the segment (in seconds). Check if the input file contains a cover image (attached picture) or any video track.
output_path: Destination path for the output file.
stream_info: Dictionary from get_stream_info().
format_opt: Output container format (e.g., 'mp3').
transcode_audio: Audio codec to transcode to (or None).
drop_video: True to remove video streams.
drop_subs: True to remove subtitle streams.
metadata: Optional dict of metadata key/value pairs to write.
Returns: Returns:
A list of commandline arguments suitable for subprocess.run(). True if a cover image or video stream is detected, False otherwise.
""" """
cmd = [ cmd = [
'ffmpeg', 'ffprobe', '-v', 'quiet',
'-i', input_file, '-print_format', 'json',
'-ss', format_time(start_seconds), '-select_streams', 'v',
'-t', format_time(duration_seconds) '-show_entries', 'stream=codec_type,codec_name,disposition',
input_file
] ]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return False
# Clear all original metadata. try:
data = json.loads(result.stdout)
streams = data.get('streams', [])
return len(streams) > 0
except (json.JSONDecodeError, KeyError):
return False
def validate_cover_images(cover_images: List[str], num_tracks: int) -> None:
"""
Validate cover image(s) before splitting.
Checks:
- Each file exists
- Only PNG and JPEG formats are accepted
- Single image OR count matches number of tracks
- File size warning for images > 4 MB
Args:
cover_images: List of cover image paths (single image or multiple).
num_tracks: Number of tracks in the tracklist.
Raises:
ValueError: If validation fails.
"""
if len(cover_images) == 0:
return
if len(cover_images) == 1 and num_tracks > 1:
# Single image is fine - will be reused for all tracks
pass
elif len(cover_images) == num_tracks:
pass
else:
raise ValueError(
f"Cover image count ({len(cover_images)}) must be 1 or match "
f"the number of tracks ({num_tracks})."
)
for img_path in cover_images:
if not os.path.exists(img_path):
raise ValueError(f"Cover image not found: {img_path}")
ext = os.path.splitext(img_path)[1].lower()
if ext not in ('.png', '.jpg', '.jpeg'):
raise ValueError(
f"Unsupported cover image format '{ext}' for '{img_path}'. "
f"Only PNG and JPEG are supported."
)
size_mb = os.path.getsize(img_path) / (1024 * 1024)
if size_mb > MAX_COVER_IMAGE_SIZE_MB:
print(
f"Warning: Cover image '{img_path}' is {size_mb:.1f} MB "
f"(exceeds {MAX_COVER_IMAGE_SIZE_MB} MB limit). "
f"Large images may cause issues."
)
def is_attached_picture(input_file: str) -> bool:
"""
Check if the input file has a video stream that is an attached picture (cover art).
Detection logic:
1. If there is a video stream with disposition.attached_pic == 1, return True.
2. Otherwise, if there is exactly one video stream and its codec is an image
format (PNG, MJPEG, JPEG, GIF, BMP), return True.
Returns:
True if a cover image is detected, False otherwise.
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-select_streams', 'v',
'-show_entries', 'stream=codec_name,disposition',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return False
try:
data = json.loads(result.stdout)
streams = data.get('streams', [])
if not streams:
return False
# Check each stream
for stream in streams:
codec = stream.get('codec_name', '').lower()
disposition = stream.get('disposition', {})
# If attached_pic is set, it's a cover image
if disposition.get('attached_pic') == 1:
return True
# If not, check if it's an image codec and we have exactly one video stream
if codec in ('png', 'mjpeg', 'jpeg', 'gif', 'bmp'):
# If there is exactly one video stream, treat it as cover
if len(streams) == 1:
return True
return False
except (json.JSONDecodeError, KeyError):
return False
def extract_cover_image(input_file: str, output_path: str) -> bool:
"""
Extract the first frame of the video stream (assumed to be an attached picture)
and save it to output_path.
Returns:
True if extraction succeeded, False otherwise.
"""
cmd = [
'ffmpeg', '-i', input_file,
'-map', '0:v:0',
'-frames:v', '1',
'-y',
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"Failed to extract cover image: {result.stderr}")
return False
return True
def _build_inputs(cmd: List[str], input_file: str, cover_image_path: Optional[str]) -> None:
"""Add input files to the command."""
if cover_image_path:
cmd.extend(['-i', cover_image_path])
cmd.extend(['-i', input_file])
def _build_time_options(cmd: List[str], start_seconds: int, duration_seconds: int) -> None:
"""Add time-based options to the command."""
cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)])
def _build_metadata(cmd: List[str], metadata: Optional[Dict]) -> None:
"""Add metadata options to the command."""
cmd.append('-map_metadata') cmd.append('-map_metadata')
cmd.append('-1') cmd.append('-1')
# Apply custom metadata.
if metadata: if metadata:
for key, value in metadata.items(): for key, value in metadata.items():
if value is not None and value != '': if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"]) cmd.extend(['-metadata', f"{key}={value}"])
# Stream mapping.
def _build_cover_image_mapping(
cmd: List[str],
audio_codec: Optional[str],
video_codec: Optional[str],
video_quality: Optional[int],
) -> None:
"""Build stream mapping for cover image extraction (audio + cover video)."""
cmd.extend(['-map', '1:a:0', '-map', '0:v:0'])
if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', video_codec])
else:
cmd.extend(['-c:v', 'png'])
if audio_codec and audio_codec != 'copy':
cmd.extend(['-c:a', audio_codec])
else:
cmd.extend(['-c:a', 'copy'])
# Preserve attached_pic disposition for cover image
cmd.extend(['-disposition', 'attached_pic'])
cmd.append('-sn')
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
def _build_standard_mapping(
cmd: List[str],
stream_info: Dict,
audio_codec: Optional[str],
video_codec: Optional[str],
subtitle_codec: Optional[str],
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> None:
"""Build stream mapping for standard extraction (no cover image)."""
if drop_video and drop_subs: if drop_video and drop_subs:
cmd.extend(['-map', '0:a:0']) cmd.extend(['-map', '0:a:0'])
elif drop_video: elif drop_video:
@@ -228,32 +420,97 @@ def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds:
else: else:
cmd.extend(['-map', '0']) cmd.extend(['-map', '0'])
# Audio codec. if audio_codec and audio_codec != 'copy':
if transcode_audio: cmd.extend(['-c:a', audio_codec])
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: else:
cmd.extend(['-c:a', 'copy']) cmd.extend(['-c:a', 'copy'])
# Video codec. if not drop_video and stream_info.get('has_video'):
if not drop_video and stream_info['has_video']: if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', 'copy']) cmd.extend(['-c:v', video_codec])
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
else:
cmd.extend(['-c:v', 'copy'])
else: else:
cmd.append('-vn') cmd.append('-vn')
# Subtitle codec. if not drop_subs and stream_info.get('has_subtitle'):
if not drop_subs and stream_info['has_subtitle']: if subtitle_codec and subtitle_codec != 'copy':
cmd.extend(['-c:s', 'copy']) cmd.extend(['-c:s', subtitle_codec])
else:
cmd.extend(['-c:s', 'copy'])
else: else:
cmd.append('-sn') cmd.append('-sn')
# Output format.
def _build_format(cmd: List[str], format_opt: Optional[str]) -> None:
"""Add output format option if specified."""
if format_opt: 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])
def _build_output(cmd: List[str], output_path: str) -> None:
"""Add output file to the command."""
cmd.extend(['-y', output_path]) cmd.extend(['-y', output_path])
def build_ffmpeg_command(
input_file: str,
start_seconds: int,
duration_seconds: int,
output_path: str,
stream_info: Dict,
format_opt: Optional[str],
audio_codec: Optional[str] = 'copy',
video_codec: Optional[str] = 'copy',
subtitle_codec: Optional[str] = 'copy',
metadata: Optional[Dict] = None,
cover_image_path: Optional[str] = None,
video_quality: Optional[int] = None,
drop_video: bool = False,
drop_subs: bool = False,
) -> List[str]:
"""
Construct the FFmpeg command line as a list of arguments.
Args:
input_file: Path to the input media file.
start_seconds: Start time for the segment (in seconds).
duration_seconds: Duration of the segment (in seconds).
output_path: Destination path for the output file.
stream_info: Dictionary from get_stream_info().
format_opt: Output container format (e.g., 'mp3', 'mkv').
audio_codec: Audio codec to use ('copy' or encoder name like 'libopus').
video_codec: Video codec to use ('copy' or encoder name).
subtitle_codec: Subtitle codec to use ('copy' or encoder name).
metadata: Optional dict of metadata key/value pairs to write.
cover_image_path: Path to extracted cover image (if any).
video_quality: Quality value for video encoder (e.g., 1-31, lower=better).
drop_video: If True, remove video streams.
drop_subs: If True, remove subtitle streams.
Returns:
A list of commandline arguments suitable for subprocess.run().
"""
cmd = ['ffmpeg']
_build_inputs(cmd, input_file, cover_image_path)
_build_time_options(cmd, start_seconds, duration_seconds)
_build_metadata(cmd, metadata)
if cover_image_path:
_build_cover_image_mapping(cmd, audio_codec, video_codec, video_quality)
else:
_build_standard_mapping(
cmd, stream_info, audio_codec, video_codec, subtitle_codec,
video_quality, drop_video, drop_subs
)
_build_format(cmd, format_opt)
_build_output(cmd, output_path)
return cmd return cmd
+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.
+74 -67
View File
@@ -1,19 +1,28 @@
# audio_splitter/formats.py
"""Container format decision and validation.""" """Container format decision and validation."""
from typing import Dict, Optional from typing import Dict, Optional
from .constants import FORMAT_INFO from .constants import (
CONTAINER_NAMES,
CONTAINER_INFO,
CODEC_TO_CONTAINER_MAP,
COMPATIBILITY_MATRIX,
is_audio_codec_supported,
is_video_codec_supported,
)
from .defaults import DEFAULT_FORMAT
def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]: def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
""" """
Given the container format and audio codec, determine the recommended output format. Given the container format and audio codec, determine the recommended output format.
This is used when the user has not explicitly specified a format. Uses the CODEC_TO_CONTAINER_MAP to map codec → container.
It prioritizes the codec to choose the most appropriate container/extension. If the codec is not found, it falls back to the container.
Args: Args:
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format(). container: Container name (e.g., 'ogg', 'mp4', '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:
@@ -22,37 +31,13 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) ->
if not container: if not container:
return None return None
# Codec-based decisions (highest priority) if codec and codec in CODEC_TO_CONTAINER_MAP:
if codec == 'opus': fmt = CODEC_TO_CONTAINER_MAP[codec]
return 'opus' if fmt in CONTAINER_NAMES:
if codec in ('aac', 'alac', 'he-aac'): return fmt
return 'm4a'
if codec == 'mp3':
return 'mp3'
if codec == 'vorbis':
return 'ogg'
if codec == 'flac':
return 'flac'
# Container-based fallback (lower priority) if container in CONTAINER_NAMES:
if container in ('mp4', 'm4a', 'mov', '3gp'): return container
return 'mp4'
if container in ('matroska', 'webm'):
return 'matroska'
if container in ('ogg',):
return 'ogg'
if container in ('mp3', 'mpeg'):
return 'mp3'
if container == 'flac':
return 'flac'
if container == 'wav':
return 'wav'
if container == 'aac':
return 'aac'
if container == 'opus':
return 'opus'
if container == 'amr':
return 'amr'
return None return None
@@ -62,69 +47,91 @@ 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
- MP3 if the audio codec is MP3 - MP3 if the audio codec is MP3
- MP4 (M4A) otherwise - MP4 (M4A) otherwise
Args:
stream_info: Dict from get_stream_info().
user_format: Userrequested format (or None).
transcode_audio: Audio codec to transcode to (or None) (unused in this function).
input_file: Path to the input file (optional, used to detect container and codec).
Returns:
A format name that exists in FORMAT_INFO.
""" """
if user_format: if user_format:
# 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 is provided, try to detect container and codec
if input_file: if input_file:
try: try:
from .ffmpeg import get_container_format, get_audio_codec from .ffmpeg import get_container_format, get_audio_codec
container = get_container_format(input_file) container = get_container_format(input_file)
codec = get_audio_codec(input_file) audio_codec = get_audio_codec(input_file)
fmt = determine_default_format(container, codec) fmt = determine_default_format(container, audio_codec)
if fmt in FORMAT_INFO: if fmt in CONTAINER_NAMES:
return fmt return fmt
except Exception: except Exception:
# If detection fails, fall through to legacy logic
pass pass
# Fallback: legacy behavior # Fallback
if stream_info.get('has_video') or stream_info.get('has_subtitle'): if stream_info.get('has_video') or stream_info.get('has_subtitle'):
return 'matroska' return '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'
else: else:
return 'mp4' # .m4a return 'mp4'
def validate_format_compatibility(format_name: str, stream_info: Dict, def validate_format_compatibility(
drop_video: bool, drop_subs: bool) -> None: container: str,
stream_info: Dict,
drop_video: bool,
drop_subs: bool,
input_file: Optional[str] = None,
audio_codec: Optional[str] = None,
video_codec: Optional[str] = None,
) -> None:
""" """
Ensure the chosen container can accommodate the streams we intend to keep. Ensure the chosen container and codec combination is valid.
Raises: Raises:
ValueError: If the format is incompatible with the intended streams. ValueError: If the combination is incompatible.
""" """
info = FORMAT_INFO.get(format_name) info = next((c for c in CONTAINER_INFO if c['name'] == container), None)
if not info: if not info:
print(f"Warning: Unknown format '{format_name}'. Proceeding, but may fail.") print(f"Warning: Unknown container '{container}'. Proceeding, but may fail.")
return return
if info['audio_only']: # Check if container is audio-only and video is present (unless dropped)
if stream_info.get('has_video') and not drop_video: if not info['supports_video'] and stream_info.get('has_video') and not drop_video:
raise ValueError(
f"Container '{container}' does not support video streams. "
"Please use --drop-video or choose a container that supports video (e.g., MKV, MP4)."
)
# Determine actual audio codec for validation
# When 'copy' is used, we must still validate the input codec against the container
actual_audio_codec = audio_codec if audio_codec and audio_codec != 'copy' else stream_info.get('audio_codec')
if actual_audio_codec:
if not is_audio_codec_supported(container, actual_audio_codec):
raise ValueError( raise ValueError(
f"Format '{format_name}' does not support video streams. " f"Container '{container}' does not support audio codec '{actual_audio_codec}'. "
"Please use --drop-video or choose a container that supports video." f"Please choose a different container or audio codec."
)
if stream_info.get('has_subtitle') and not drop_subs:
raise ValueError(
f"Format '{format_name}' does not support subtitle streams. "
"Please use --drop-subs or choose a container that supports subtitles."
) )
# Check if video codec is supported (if video is present and not dropped)
if stream_info.get('has_video') and not drop_video:
# Determine the video codec from input file if not provided
if video_codec is None and input_file:
from .ffmpeg import get_video_codec
video_codec = get_video_codec(input_file)
# When 'copy' is used, validate the input video codec against the container
if video_codec == 'copy' and input_file:
from .ffmpeg import get_video_codec
video_codec = get_video_codec(input_file)
if video_codec:
if not is_video_codec_supported(container, video_codec):
raise ValueError(
f"Container '{container}' does not support video codec '{video_codec}'. "
f"Please choose a different container, drop video, or transcode video to a supported codec."
)
+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
+147 -89
View File
@@ -1,139 +1,194 @@
"""Commandline interface and entry point.""" #!/usr/bin/env python3
"""
Audio Splitter CommandLine Interface
Split an audio file into tracks using a tracklist file.
Usage:
audio_splitter input.mp3 tracklist.txt [OPTIONS]
"""
import os import os
import sys import sys
import subprocess import subprocess
import argparse import argparse
from .constants import DEFAULT_BAD_CHARS from .defaults import (
from .tracklist import read_tracklist, parse_format DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE,
DEFAULT_REPLACEMENT_CHAR,
DEFAULT_BAD_CHARS,
DEFAULT_TRACKLIST_FORMAT,
DEFAULT_ALBUM,
DEFAULT_COMMENT,
DEFAULT_COMMENT_STREAM,
DEFAULT_COMMENT_SEPARATOR,
DEFAULT_NO_COMMENT,
DEFAULT_MERGE_COMMENTS,
DEFAULT_DROP_VIDEO,
DEFAULT_DROP_SUBS,
DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING,
DEFAULT_DELETE_ORIGINAL,
)
from .constants import CONTAINER_NAMES
from .core import split_audio from .core import split_audio
from .tracklist import read_tracklist, parse_format
from .ffmpeg import has_cover_or_video, validate_cover_images
def main(): def main():
"""Parse arguments and start the splitting process."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Split an audio file into tracks using a tracklist.", description="Split an audio file into tracks using a tracklist.",
epilog="Tracklist format: mm:ss track_name - author_name" epilog="Tracklist format: mm:ss track_name - author_name (or custom with --tracklist-format)"
) )
# Positional arguments. # Positional
parser.add_argument('input_file', help='Input audio/video file') parser.add_argument('input_file', help='Input audio file')
parser.add_argument('tracklist_file', help='Tracklist file') parser.add_argument('tracklist_file', help='Tracklist file')
# Optional arguments. # Container and codec options
parser.add_argument( parser.add_argument(
'--output-dir', '-o', '--container',
help='Output directory for split tracks (default: <input_basename>_splits)' default=None,
help="Output container format (auto-detect if not specified)"
) )
parser.add_argument( parser.add_argument(
'--format', '--audio-codec',
help='Output container format (e.g., mp3, m4a, mkv, mp4, ogg, opus)' default='copy',
help="Audio codec (copy or encoder name, e.g., libopus)"
) )
parser.add_argument( parser.add_argument(
'--transcode-to', '--video-codec',
help='Re-encode audio to this codec (e.g., libmp3lame, aac, libopus)' 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( parser.add_argument(
'--drop-video', '--drop-video',
action='store_true', action='store_true',
help='Remove video streams from output' default=DEFAULT_DROP_VIDEO,
help="Drop video streams"
) )
parser.add_argument( parser.add_argument(
'--drop-subs', '--drop-subs',
action='store_true', action='store_true',
help='Remove subtitle streams from output' default=DEFAULT_DROP_SUBS,
help="Drop subtitle streams"
) )
# Filename options
parser.add_argument( parser.add_argument(
'--number-tracks', '--number-tracks',
action='store_true', action='store_true',
help='Prepend track number to output filenames (convenience; use %%num in template for full control)' 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( parser.add_argument(
'--replace-bad-chars', '--replace-bad-chars',
action='store_true', action='store_true',
help='Replace problematic characters in filenames (default: off)' default=DEFAULT_REPLACE_BAD_CHARS,
help="Replace bad characters"
) )
parser.add_argument( parser.add_argument(
'--replacement-char', '--replacement-char',
default='_', default=DEFAULT_REPLACEMENT_CHAR,
help='Character used as replacement (default: "_")' help="Replacement character (default: %(default)s)"
) )
parser.add_argument( parser.add_argument(
'--bad-chars', '--bad-chars',
default=DEFAULT_BAD_CHARS, default=DEFAULT_BAD_CHARS,
help='String of characters to replace (default includes space and single quote)' help="Bad characters to replace (default: %(default)s)"
) )
parser.add_argument( parser.add_argument(
'--skip-existing', '--skip-existing',
action='store_true', action='store_true',
help='Skip extraction if output file already exists (default: overwrite)' default=DEFAULT_SKIP_EXISTING,
) help="Skip existing output files"
parser.add_argument(
'--tracklist-format',
default='%ts %tn - %an',
help='Format of each line in the tracklist using placeholders: '
'%%ts (timestamp), %%tn (track name), %%an (author), %%al (album), '
'%%date (date/year), %%ext (file extension). '
'Default: "%%ts %%tn - %%an"'
)
parser.add_argument(
'--output-template',
default='%an-%tn.%ext',
help='Template for output filenames using placeholders: '
'%%tn (track name), %%an (author), %%al (album), '
'%%date (date/year), %%ext (file extension), %%num (track number). '
'Default: "%%an-%%tn.%%ext"'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Parse and display the tracklist without splitting any files'
) )
# Metadata options. # Metadata
parser.add_argument( parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name")
'--album', parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment")
help='Set album name in output metadata (overrides parsed %%al and original album)'
)
parser.add_argument(
'--comment',
help='Explicit comment text (overrides all other comment settings)'
)
parser.add_argument( parser.add_argument(
'--no-comment', '--no-comment',
action='store_true', action='store_true',
help='Explicitly ignore any comment (no comment written)' default=DEFAULT_NO_COMMENT,
help="Ignore comment"
) )
parser.add_argument( parser.add_argument(
'--comment-stream', '--comment-stream',
type=int, type=int,
default=None, default=DEFAULT_COMMENT_STREAM,
help='Select comment from a specific stream index (0based). Default: first stream with a comment.' help="Comment stream index"
) )
parser.add_argument( parser.add_argument(
'--merge-comments', '--merge-comments',
action='store_true', action='store_true',
help='Merge all comments from all streams into one (separated by --comment-separator)' default=DEFAULT_MERGE_COMMENTS,
help="Merge all comments"
) )
parser.add_argument( parser.add_argument(
'--comment-separator', '--comment-separator',
default='; ', default=DEFAULT_COMMENT_SEPARATOR,
help='Separator used when merging comments (default: "; ")' help="Separator for merged comments (default: %(default)s)"
) )
# NEW: Delete original after successful split. # Tracklist format
parser.add_argument(
'--tracklist-format',
default=DEFAULT_TRACKLIST_FORMAT,
help="Tracklist format (default: %(default)s)"
)
# Cover image
parser.add_argument(
'--cover-image',
nargs='+',
metavar='IMAGE',
default=None,
help="Cover image path(s). Single image applied to all tracks, "
"or one per track (must match track count)."
)
# Other
parser.add_argument( parser.add_argument(
'--delete-original', '--delete-original',
action='store_true', action='store_true',
help='Delete the original input file after successful splitting (default: keep)' default=DEFAULT_DELETE_ORIGINAL,
help="Delete original file after split"
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Parse and display tracklist without splitting"
)
parser.add_argument(
'--output-dir', '-o',
default=None,
help="Output directory (default: <input_basename>_splits)"
) )
args = parser.parse_args() args = parser.parse_args()
# -------------------------------------------------------------------------- # Validate input
# Input validation
# --------------------------------------------------------------------------
if not os.path.exists(args.input_file): if not os.path.exists(args.input_file):
print(f"Error: Input file not found: {args.input_file}") print(f"Error: Input file not found: {args.input_file}")
sys.exit(1) sys.exit(1)
@@ -142,12 +197,13 @@ def main():
print(f"Error: Tracklist file not found: {args.tracklist_file}") print(f"Error: Tracklist file not found: {args.tracklist_file}")
sys.exit(1) sys.exit(1)
# Ensure the replacement character is a single character. # Validate container if provided
if len(args.replacement_char) != 1: if args.container and args.container not in CONTAINER_NAMES:
print("Error: --replacement-char must be a single character.") print(f"Error: Invalid container '{args.container}'. "
f"Valid containers: {', '.join(sorted(CONTAINER_NAMES))}.")
sys.exit(1) sys.exit(1)
# Check that FFmpeg is installed. # Check FFmpeg
try: try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True) subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
@@ -155,14 +211,14 @@ def main():
print(" - https://ffmpeg.org/download.html") print(" - https://ffmpeg.org/download.html")
sys.exit(1) sys.exit(1)
# Parse the tracklist using the userprovided format. # Parse tracklist format
try: try:
tokens = parse_format(args.tracklist_format) tokens = parse_format(args.tracklist_format)
except ValueError as error: except ValueError as e:
print(f"Error in --tracklist-format: {error}") print(f"Error in --tracklist-format: {e}")
sys.exit(1) sys.exit(1)
# Read and parse the tracklist file. # Read tracklist
tracks = read_tracklist(args.tracklist_file, tokens) tracks = read_tracklist(args.tracklist_file, tokens)
if not tracks: if not tracks:
print("Error: No valid tracks found in tracklist file.") print("Error: No valid tracks found in tracklist file.")
@@ -170,7 +226,21 @@ def main():
print(f"Found {len(tracks)} tracks.") print(f"Found {len(tracks)} tracks.")
# Dryrun mode: display parsed data and exit. # Validate cover images if provided
if args.cover_image:
# Check if input already has cover image or video track
# Allow if --drop-video is specified (user wants to discard existing video)
if has_cover_or_video(args.input_file) and not args.drop_video:
print("Error: Input file already contains a cover image or video track. "
"Remove it first, or use --drop-video to discard video streams.")
sys.exit(1)
try:
validate_cover_images(args.cover_image, len(tracks))
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
if args.dry_run: if args.dry_run:
print("\nParsed tracklist:") print("\nParsed tracklist:")
print("-" * 60) print("-" * 60)
@@ -184,34 +254,22 @@ def main():
print(f"{idx:3d} | " + " | ".join(values)) print(f"{idx:3d} | " + " | ".join(values))
print("-" * 60) print("-" * 60)
print("Dryrun complete. No files were created.") print("Dryrun complete. No files were created.")
sys.exit(0) return
# Determine the output directory. # Determine output directory
if args.output_dir: if args.output_dir:
output_dir = args.output_dir output_dir = args.output_dir
print(f"Using custom output directory: {output_dir}")
else: else:
base_name = os.path.splitext(os.path.basename(args.input_file))[0] base_name = os.path.splitext(os.path.basename(args.input_file))[0]
output_dir = base_name + "_splits" output_dir = base_name + "_splits"
print(f"Using default output directory: {output_dir}")
# Run the splitter. # Run split
try: try:
split_audio(args.input_file, output_dir, tracks, args) split_audio(args.input_file, output_dir, tracks, args)
except (RuntimeError, ValueError) as error: except Exception as e:
print(f"Error: {error}") print(f"Error during split: {e}")
sys.exit(1) sys.exit(1)
# --------------------------------------------------------------------------
# Delete original file if requested and successful.
# --------------------------------------------------------------------------
if args.delete_original:
try:
os.remove(args.input_file)
print(f"Deleted original file: {args.input_file}")
except OSError as e:
print(f"Warning: Could not delete original file: {e}")
print("Done!") print("Done!")
+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
-37
View File
@@ -1,37 +0,0 @@
services:
backend:
build:
context: .
dockerfile: web/backend/Dockerfile
volumes:
# Bind mount for source code (hot-reload for Python)
- ./web/backend:/app/backend
- ./audio_splitter:/app/audio_splitter
- ./setup.py:/app/setup.py
- ./pyproject.toml:/app/pyproject.toml
# Development data directory (overrides production)
- "./dev_data:/tmp/audio_splitter_web"
environment:
- DEBUG=1
- PYTHONUNBUFFERED=1
ports:
- "8000:8000"
frontend:
build:
context: ./web/frontend
# Use the same Dockerfile, but override CMD for development
dockerfile: Dockerfile
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
volumes:
# Bind mount for source code (hot-reload for Vite)
- ./web/frontend:/app
- node_modules:/app/node_modules
environment:
- BACKEND_URL=http://backend:8000
- VITE_BACKEND_URL=http://backend:8000
ports:
- "5173:5173"
volumes:
node_modules:
+10 -5
View File
@@ -1,19 +1,24 @@
services: services:
backend: backend:
image: git.vmn.su/max/audio_splitter_backend:latest build:
context: .
dockerfile: web/backend/Dockerfile
#image: max/audio_splitter_backend:latest
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:-8000}:8000"
volumes: volumes:
# Bind mount for persistent data storage. #- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
# Set BACKEND_DATA_DIR in .env to point to your data directory. - "/tmp/test:/tmp/audio_splitter_web"
- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DEBUG=${DEBUG:-0} - DEBUG=${DEBUG:-0}
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: git.vmn.su/max/audio_splitter_frontend:latest build:
context: .
dockerfile: web/frontend/Dockerfile
#image: max/audio_splitter_frontend:latest
ports: ports:
- "${FRONTEND_PORT:-5173}:80" - "${FRONTEND_PORT:-5173}:80"
depends_on: depends_on:
-18
View File
@@ -1,18 +0,0 @@
#!/bin/bash
set -e
# Check if we are running as root (default)
if [ "$(id -u)" = "0" ]; then
# If /data is a directory, change its ownership to the container user
if [ -d "/data" ]; then
echo "Setting ownership of /data to appuser:appgroup"
chown -R appuser:appgroup /data
fi
# Drop privileges and run audio_splitter with the provided arguments
exec gosu appuser audio_splitter "$@"
else
# If not root, just run audio_splitter directly
# It's not supposed to be called
exec audio_splitter "$@"
fi
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Generate TypeScript constants from audio_splitter/defaults.py.
"""
import importlib.util
import json
import sys
from pathlib import Path
# Get the directory where this script is located
SCRIPT_DIR = Path(__file__).parent.absolute()
# The defaults.py is in the same directory as the script
DEFAULTS_PATH = SCRIPT_DIR / "defaults.py"
# The frontend source is at SCRIPT_DIR.parent (i.e., /app)
# The generated file should be at /app/src/constants/generated.ts
FRONTEND_ROOT = SCRIPT_DIR.parent
OUTPUT_PATH = FRONTEND_ROOT / "src" / "constants" / "generated.ts"
def load_defaults_module():
spec = importlib.util.spec_from_file_location("defaults", DEFAULTS_PATH)
if spec is None:
raise RuntimeError(f"Could not load spec for {DEFAULTS_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def format_value(value):
"""Convert Python value to TypeScript literal."""
if value is None:
return "null"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, str):
# Use json.dumps to produce a properly escaped string literal
return json.dumps(value)
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, list):
return f"[{', '.join(format_value(v) for v in value)}]"
return repr(value)
def main():
print(f"Generating TypeScript defaults from {DEFAULTS_PATH}")
defaults = load_defaults_module()
constants = {name: value for name, value in vars(defaults).items() if name.startswith("DEFAULT_")}
if not constants:
print("No DEFAULT_* constants found.")
sys.exit(1)
lines = [
"// ============================================================================",
"// GENERATED FILE DO NOT EDIT MANUALLY.",
"// This file is generated from audio_splitter/defaults.py.",
"// Run `npm run generate` or `python scripts/generate_ts_defaults.py` to update.",
"// ============================================================================",
"",
]
for name, value in sorted(constants.items()):
ts_value = format_value(value)
lines.append(f"export const {name} = {ts_value};")
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text("\n".join(lines) + "\n")
print(f"✅ Generated {OUTPUT_PATH}")
print(f" {len(constants)} constants exported.")
if __name__ == "__main__":
main()
+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 \
&& \ && \
+1 -2
View File
@@ -1,4 +1,3 @@
"""API route handlers.""" """API route handlers."""
from . import upload, split, status, download from . import upload, split, split_file, status, download, websocket, formats, info
# websocket is imported directly in main.py to avoid circular import
+6 -11
View File
@@ -1,8 +1,9 @@
# web/backend/api/formats.py
"""Endpoint to expose format information to the frontend.""" """Endpoint to expose format information to the frontend."""
from fastapi import APIRouter from fastapi import APIRouter
from backend.constants import FORMAT_INFO from audio_splitter.constants import CONTAINER_INFO, CODEC_INFO, COMPATIBILITY_MATRIX
router = APIRouter(prefix="/api", tags=["formats"]) router = APIRouter(prefix="/api", tags=["formats"])
@@ -10,16 +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 container formats with their properties. Return the list of supported containers, codecs, and compatibility matrix.
""" """
return { return {
"formats": [ "containers": CONTAINER_INFO,
{ "codecs": CODEC_INFO,
"name": name, "compatibility": COMPATIBILITY_MATRIX,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
} }
+39 -2
View File
@@ -5,7 +5,12 @@ from fastapi import APIRouter, HTTPException
from backend.config import settings from backend.config import settings
from backend.services.file_manager import FileManager from backend.services.file_manager import FileManager
from backend.services.task_manager import task_manager from backend.services.task_manager import task_manager
from backend.ffmpeg import get_stream_info
# Import core functions
from audio_splitter.ffmpeg import get_stream_info, get_container_format, get_audio_codec
from audio_splitter.formats import determine_default_format
from audio_splitter.constants import CONTAINER_NAMES
from audio_splitter.defaults import DEFAULT_FORMAT
router = APIRouter(prefix="/api", tags=["info"]) router = APIRouter(prefix="/api", tags=["info"])
@@ -13,7 +18,8 @@ router = APIRouter(prefix="/api", tags=["info"])
@router.get("/info/{task_id}") @router.get("/info/{task_id}")
async def get_task_info(task_id: str): async def get_task_info(task_id: str):
""" """
Return stream information (has_audio, has_video, has_subtitle) for the uploaded file. Return stream information (has_audio, has_video, has_subtitle, audio_codec)
for the uploaded file.
""" """
if not task_manager.has_task(task_id): if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found") raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
@@ -33,3 +39,34 @@ async def get_task_info(task_id: str):
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
@router.get("/info/recommended-format/{task_id}")
async def get_recommended_format(task_id: str):
"""
Return the recommended output format (container name) for the uploaded file,
based on its container and audio codec.
"""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
raise HTTPException(status_code=404, detail="Input file not found")
try:
container = get_container_format(str(input_path))
codec = get_audio_codec(str(input_path))
fmt = determine_default_format(container, codec)
# Fallback if detection fails or format is unsupported
if fmt is None:
fmt = DEFAULT_FORMAT
if fmt not in CONTAINER_NAMES:
fmt = "mp3" # ultimate fallback
return {"format": fmt}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to determine format: {str(e)}")
+30 -2
View File
@@ -1,4 +1,4 @@
"""Split task endpoint.""" """Split task endpoint (JSON tracklist)."""
from fastapi import APIRouter, HTTPException, BackgroundTasks from fastapi import APIRouter, HTTPException, BackgroundTasks
@@ -24,6 +24,29 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
detail=f"Task {task_id} is already {status['status']}" detail=f"Task {task_id} is already {status['status']}"
) )
# Build options dict from request
options = {
"container": request.container,
"audio_codec": request.audio_codec,
"video_codec": request.video_codec,
"subtitle_codec": request.subtitle_codec,
"video_quality": request.video_quality,
"drop_video": request.drop_video,
"drop_subs": request.drop_subs,
"number_tracks": request.number_tracks,
"replace_bad_chars": request.replace_bad_chars,
"replacement_char": request.replacement_char,
"bad_chars": request.bad_chars,
"skip_existing": request.skip_existing,
"output_template": request.output_template,
"album": request.album,
"comment": request.comment,
"no_comment": request.no_comment,
"comment_stream": request.comment_stream,
"merge_comments": request.merge_comments,
"comment_separator": request.comment_separator,
}
task_manager.update_task( task_manager.update_task(
task_id, task_id,
status=TaskStatus.PROCESSING, status=TaskStatus.PROCESSING,
@@ -31,7 +54,12 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
message="Preparing to split..." message="Preparing to split..."
) )
background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options) background_tasks.add_task(
run_split_task,
task_id,
request.tracklist,
options
)
return SplitResponse( return SplitResponse(
task_id=task_id, task_id=task_id,
+151
View File
@@ -0,0 +1,151 @@
"""Endpoint for splitting with a tracklist file upload."""
import json
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.services.splitter import run_split_task
from backend.models.request import TracklistEntry
from backend.models.response import SplitResponse, TaskStatus
router = APIRouter(prefix="/api", tags=["split"])
from audio_splitter.tracklist import parse_format, read_tracklist
@router.post("/split-file", response_model=SplitResponse)
async def split_from_file(
background_tasks: BackgroundTasks,
task_id: str = Form(...),
tracklist_file: UploadFile = File(...),
# 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"),
):
# Validate task exists
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
raise HTTPException(
status_code=409,
detail=f"Task {task_id} is already {status['status']}"
)
if not tracklist_file.filename.endswith(('.txt', '.text')):
raise HTTPException(
status_code=400,
detail="Tracklist file must be a text file (.txt or .text)"
)
try:
content = await tracklist_file.read()
text = content.decode('utf-8')
except UnicodeDecodeError:
raise HTTPException(
status_code=400,
detail="Tracklist file must be UTF-8 encoded"
)
if not text.strip():
raise HTTPException(
status_code=400,
detail="Tracklist file is empty"
)
# Parse tracklist using CLI logic
try:
tokens = parse_format(tracklist_format)
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')
tracklist_dicts = read_tracklist(str(temp_tracklist_path), tokens)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to parse tracklist: {str(e)}"
)
if not tracklist_dicts:
raise HTTPException(
status_code=400,
detail="No valid tracks found in tracklist file"
)
# Convert dicts to TracklistEntry objects
try:
tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts]
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Invalid tracklist data: {str(e)}"
)
# 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(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Preparing to split..."
)
background_tasks.add_task(
run_split_task,
task_id,
tracklist_entries,
options
)
return SplitResponse(
task_id=task_id,
status=TaskStatus.PROCESSING
)
+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},
}
+4 -3
View File
@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from backend.config import settings from backend.config import settings
from backend.api import upload, split, status, download, websocket, formats, info from backend.api import upload, split, split_file, status, download, websocket, formats, info
from backend.services import progress_publisher from backend.services import progress_publisher
app = FastAPI( app = FastAPI(
@@ -33,11 +33,12 @@ async def startup_event():
# Include routers # Include routers
app.include_router(upload.router) app.include_router(upload.router)
app.include_router(split.router) app.include_router(split.router)
app.include_router(split_file.router) # NEW
app.include_router(status.router) app.include_router(status.router)
app.include_router(download.router) app.include_router(download.router)
app.include_router(websocket.router) app.include_router(websocket.router)
app.include_router(formats.router) # new app.include_router(formats.router)
app.include_router(info.router) # new app.include_router(info.router)
@app.get("/") @app.get("/")
+27 -5
View File
@@ -17,19 +17,16 @@ class TracklistEntry(BaseModel):
@validator("ts") @validator("ts")
def validate_timestamp(cls, v: str) -> str: def validate_timestamp(cls, v: str) -> str:
"""Basic timestamp validation (format and range)."""
v = v.strip() v = v.strip()
if not v: if not v:
raise ValueError("Timestamp cannot be empty") raise ValueError("Timestamp cannot be empty")
# Check for range format (start-end)
if "-" in v: if "-" in v:
parts = v.split("-", 1) parts = v.split("-", 1)
start = parts[0].strip() start = parts[0].strip()
end = parts[1].strip() end = parts[1].strip()
if not start or not end: if not start or not end:
raise ValueError("Invalid range format. Expected 'start-end'") raise ValueError("Invalid range format. Expected 'start-end'")
# Validate each part with the same logic
for ts in [start, end]: for ts in [start, end]:
cls._validate_single_timestamp(ts) cls._validate_single_timestamp(ts)
else: else:
@@ -39,7 +36,6 @@ class TracklistEntry(BaseModel):
@staticmethod @staticmethod
def _validate_single_timestamp(ts: str) -> None: def _validate_single_timestamp(ts: str) -> None:
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
parts = ts.split(":") parts = ts.split(":")
if len(parts) not in (2, 3): if len(parts) not in (2, 3):
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS") raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS")
@@ -55,4 +51,30 @@ class SplitRequest(BaseModel):
task_id: str = Field(..., description="Task ID from upload") task_id: str = Field(..., description="Task ID from upload")
tracklist: List[TracklistEntry] = Field(..., description="List of tracks") tracklist: List[TracklistEntry] = Field(..., description="List of tracks")
options: dict = Field(default_factory=dict, description="All CLI options")
# Container and codec options
container: Optional[str] = Field(None, description="Output container (auto-detect if None)")
audio_codec: Optional[str] = Field("copy", description="Audio codec (copy or encoder name)")
video_codec: Optional[str] = Field("copy", description="Video codec (copy or encoder name)")
subtitle_codec: Optional[str] = Field("copy", description="Subtitle codec (copy or encoder name)")
video_quality: Optional[int] = Field(None, description="Video quality (encoder-specific integer)")
# Stream handling
drop_video: bool = Field(False, description="Remove video streams")
drop_subs: bool = Field(False, description="Remove subtitle streams")
# Filename options
number_tracks: bool = Field(False, description="Prepend track numbers")
replace_bad_chars: bool = Field(False, description="Replace bad characters")
replacement_char: str = Field("_", description="Replacement character")
bad_chars: str = Field(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ', description="Bad characters to replace")
skip_existing: bool = Field(False, description="Skip existing files")
output_template: str = Field("%an-%tn.%ext", description="Output filename template")
# Metadata options
album: Optional[str] = Field(None, description="Album name")
comment: Optional[str] = Field(None, description="Comment")
no_comment: bool = Field(False, description="Ignore comment")
comment_stream: Optional[int] = Field(None, description="Comment stream index")
merge_comments: bool = Field(False, description="Merge all comments")
comment_separator: str = Field("; ", description="Separator for merged comments")
+10 -7
View File
@@ -40,9 +40,13 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from types import SimpleNamespace from types import SimpleNamespace
# Build args namespace using the new fields
args = SimpleNamespace( args = SimpleNamespace(
format=options.get("format", "mp3"), container=options.get("container"), # None -> auto-detect
transcode_to=options.get("transcode_to", None), 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_video=options.get("drop_video", False),
drop_subs=options.get("drop_subs", False), drop_subs=options.get("drop_subs", False),
number_tracks=options.get("number_tracks", False), number_tracks=options.get("number_tracks", False),
@@ -66,17 +70,18 @@ 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
# Cover image handling is now done internally by split_audio()
# via the handler system (opustags for Opus, ffmpeg for others)
args.cover_image = None
task_manager.update_task_with_progress( task_manager.update_task_with_progress(
task_id, progress=10, message="Starting split..." task_id, progress=10, message="Starting split..."
) )
# Run the split
split_audio(str(input_path), str(output_dir), tracks, args) split_audio(str(input_path), str(output_dir), tracks, args)
# Get output files
output_files = FileManager.get_output_files(task_id) output_files = FileManager.get_output_files(task_id)
# Final status update
task_manager.update_task_with_progress( task_manager.update_task_with_progress(
task_id, task_id,
progress=100, progress=100,
@@ -84,13 +89,11 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
status=TaskStatus.DONE status=TaskStatus.DONE
) )
# Add tracks to the task state
task_manager.update_task( task_manager.update_task(
task_id, task_id,
tracks=output_files tracks=output_files
) )
# Give WebSocket time to send the final message
time.sleep(0.5) time.sleep(0.5)
except Exception as e: except Exception as e:
+27 -12
View File
@@ -1,27 +1,42 @@
# Stage 1: Build # web/frontend/Dockerfile
# Build context must be the project root.
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
# Install Python for the generation script
RUN apk add --no-cache python3 py3-pip
WORKDIR /app WORKDIR /app
# Install dependencies # Copy frontend package files and install dependencies
COPY package.json package-lock.json* ./ COPY web/frontend/package.json web/frontend/package-lock.json* ./
RUN npm install RUN npm install
# Copy the application code and build # Copy the frontend source code
COPY . . COPY web/frontend/ .
# Copy the Python defaults and the generation script into the expected location
# The generate script in package.json expects ../../scripts/generate_ts_defaults.py
# So we must place it at /app/../../scripts/ which is /scripts/
# But we can't COPY to a parent directory. Instead, we'll copy to /app/scripts/
# and adjust the package.json script to use ./scripts/generate_ts_defaults.py
# Actually, the simplest fix is to copy to /app/scripts/ and then adjust package.json.
#
# Let's use a different approach: copy to /app/scripts/ and update the generate script.
COPY audio_splitter/defaults.py /app/scripts/defaults.py
COPY scripts/generate_ts_defaults.py /app/scripts/generate_ts_defaults.py
# Run the generation script
RUN python /app/scripts/generate_ts_defaults.py
# Build the frontend
RUN npm run build RUN npm run build
# Stage 2: Production (nginx) # Stage 2: Production (nginx)
FROM nginx:alpine FROM nginx:alpine
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY web/frontend/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Copy nginx configuration
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Expose the port
EXPOSE 80 EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+3
View File
@@ -4,6 +4,9 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"generate": "python scripts/generate_ts_defaults.py",
"predev": "npm run generate",
"prebuild": "npm run generate",
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+7 -8
View File
@@ -68,16 +68,15 @@ const App: React.FC = () => {
reset() reset()
} }
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError const isSplitDisabled =
!taskId ||
!isValid ||
entries.length === 0 ||
isProcessing ||
!!formatError
return ( return (
<ThemeProvider <ThemeProvider theme={createTheme({ palette: { mode: theme } })}>
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<CssBaseline /> <CssBaseline />
<Layout> <Layout>
<Grid container spacing={3}> <Grid container spacing={3}>
+39 -10
View File
@@ -1,5 +1,12 @@
import axios from 'axios' import axios from 'axios'
import { TracklistEntry, SplitOptions, TaskStatus } from '../types' import {
TracklistEntry,
SplitOptions,
TaskStatus,
FormatsResponse,
UploadResponse,
SplitResponse,
} from '../types'
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', baseURL: '/api',
@@ -8,16 +15,14 @@ export const api = axios.create({
}, },
}) })
export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => { export const uploadFile = async (file: File): Promise<UploadResponse> => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const response = await api.post('/upload', formData, { const response = await api.post('/upload', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}) })
return response.data return response.data
} }
@@ -25,12 +30,32 @@ export const startSplit = async (
task_id: string, task_id: string,
tracklist: TracklistEntry[], tracklist: TracklistEntry[],
options: SplitOptions options: SplitOptions
): Promise<{ task_id: string; status: string }> => { ): Promise<SplitResponse> => {
const response = await api.post('/split', { // Build request payload (only send fields that are not undefined)
const payload: any = {
task_id, task_id,
tracklist, tracklist,
options, container: options.container, // null means auto-detect
}) audio_codec: options.audio_codec,
video_codec: options.video_codec,
subtitle_codec: options.subtitle_codec,
video_quality: options.video_quality,
drop_video: options.drop_video,
drop_subs: options.drop_subs,
number_tracks: options.number_tracks,
replace_bad_chars: options.replace_bad_chars,
replacement_char: options.replacement_char,
bad_chars: options.bad_chars,
skip_existing: options.skip_existing,
output_template: options.output_template,
album: options.album || null,
comment: options.comment || null,
no_comment: options.no_comment,
comment_stream: options.comment_stream,
merge_comments: options.merge_comments,
comment_separator: options.comment_separator,
}
const response = await api.post('/split', payload)
return response.data return response.data
} }
@@ -47,8 +72,7 @@ export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip` return `/api/download/${task_id}/splits.zip`
} }
// New functions for format validation feature export const getFormats = async (): Promise<FormatsResponse> => {
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
const response = await api.get('/formats') const response = await api.get('/formats')
return response.data return response.data
} }
@@ -63,3 +87,8 @@ export const getTaskInfo = async (task_id: string): Promise<{
const response = await api.get(`/info/${task_id}`) const response = await api.get(`/info/${task_id}`)
return response.data return response.data
} }
export const getRecommendedFormat = async (task_id: string): Promise<{ format: string }> => {
const response = await api.get(`/info/recommended-format/${task_id}`)
return response.data
}
+136 -73
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react' import React, { useEffect, useMemo } from 'react'
import { import {
Box, Box,
Paper, Paper,
@@ -16,7 +16,14 @@ import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore' import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore' import { useValidationStore } from '../stores/validationStore'
import { getFormats } from '../api/client'
import { SplitOptions } from '../types'
import { useFilterCodecs } from '../hooks/useFilterCodecs'
import { useFormatValidation } from '../hooks/useFormatValidation'
// ------------------------------------------------------------------------------
// Section component (collapsible)
// ------------------------------------------------------------------------------
interface SectionProps { interface SectionProps {
title: string title: string
children: React.ReactNode children: React.ReactNode
@@ -25,7 +32,6 @@ interface SectionProps {
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => { const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded) const [expanded, setExpanded] = React.useState(defaultExpanded)
return ( return (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Box <Box
@@ -51,54 +57,64 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
) )
} }
// ------------------------------------------------------------------------------
// Main OptionsPanel
// ------------------------------------------------------------------------------
export const OptionsPanel: React.FC = () => { export const OptionsPanel: React.FC = () => {
const { options, setOptions } = useOptionsStore() const {
options,
setOptions,
containers,
codecs,
compatibility,
setContainers,
setCodecs,
setCompatibility,
} = useOptionsStore()
const { hasVideo } = useUploadStore() const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore() const { formatError, setFormatError } = useValidationStore()
// Audio-only formats from backend constants (hardcoded for now) // Fetch formats on mount
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFormat = e.target.value
setOptions({ format: newFormat })
// Validate format
if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
setFormatError(
`Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
}
}
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked
setOptions({ drop_video: checked })
// Re-validate format
if (audioOnlyFormats.includes(options.format) && hasVideo && !checked) {
setFormatError(
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
}
}
// Re-validate when hasVideo changes (e.g., after upload)
useEffect(() => { useEffect(() => {
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video const fetchFormats = async () => {
const newError = shouldShowError try {
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).` const data = await getFormats()
: null setContainers(data.containers || [])
setCodecs(data.codecs || [])
// Only update if the error state actually changes setCompatibility(data.compatibility || {})
if (newError !== formatError) { } catch (err) {
setFormatError(newError) console.error('Failed to fetch formats:', err)
}
} }
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError]) fetchFormats()
}, [setContainers, setCodecs, setCompatibility])
// --------------------------------------------------------------
// Filter codec options based on selected container
// --------------------------------------------------------------
const { filteredAudioCodecs, filteredVideoCodecs } = useFilterCodecs(compatibility, options.container, codecs)
// --------------------------------------------------------------
// Validate compatibility
// --------------------------------------------------------------
useFormatValidation(options, hasVideo, compatibility, containers, setFormatError)
// --------------------------------------------------------------
// Handlers
// --------------------------------------------------------------
const handleChange = (field: keyof SplitOptions, value: any) => {
setOptions({ [field]: value })
}
const handleContainerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? null : e.target.value
handleChange('container', value)
}
// --------------------------------------------------------------
// Render
// --------------------------------------------------------------
return ( return (
<Paper sx={{ p: 3 }}> <Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
@@ -111,55 +127,101 @@ export const OptionsPanel: React.FC = () => {
</Alert> </Alert>
)} )}
{/* Output Settings */} {/* ------------------------------------------------------------------------
Output Settings
------------------------------------------------------------------------ */}
<Section title="Output Settings" defaultExpanded> <Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Container dropdown */}
<TextField <TextField
label="Format" label="Container"
select select
value={options.format} value={options.container ?? ''}
onChange={handleFormatChange} onChange={handleContainerChange}
fullWidth fullWidth
size="small" size="small"
error={!!formatError} error={!!formatError}
helperText={
formatError || "Select a container (auto-detect if empty)."
}
> >
<MenuItem value="mp3">MP3</MenuItem> <MenuItem value="">Auto-detect</MenuItem>
<MenuItem value="m4a">M4A</MenuItem> {containers.map((c) => (
<MenuItem value="mkv">MKV</MenuItem> <MenuItem key={c.name} value={c.name}>
<MenuItem value="mp4">MP4</MenuItem> {c.name.toUpperCase()} ({c.extension})
<MenuItem value="ogg">OGG</MenuItem> </MenuItem>
<MenuItem value="opus">OPUS</MenuItem> ))}
<MenuItem value="flac">FLAC</MenuItem>
<MenuItem value="wav">WAV</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
</TextField> </TextField>
{/* Audio Codec dropdown */}
<TextField <TextField
label="Transcode to" label="Audio Codec"
select select
value={options.transcode_to || ''} value={options.audio_codec}
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)} onChange={(e) => handleChange('audio_codec', e.target.value)}
fullWidth fullWidth
size="small" size="small"
helperText="Audio codec (copy = keep original)"
> >
<MenuItem value="">Copy (no transcoding)</MenuItem> <MenuItem value="copy">Copy (original)</MenuItem>
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem> {filteredAudioCodecs.map((c) => (
<MenuItem value="aac">AAC</MenuItem> <MenuItem key={c.name} value={c.name}>
<MenuItem value="libopus">OPUS</MenuItem> {c.name.toUpperCase()}
</MenuItem>
))}
</TextField> </TextField>
{/* Video Codec dropdown */}
<TextField
label="Video Codec"
select
value={options.video_codec}
onChange={(e) => handleChange('video_codec', e.target.value)}
fullWidth
size="small"
helperText="Video codec (copy = keep original)"
disabled={!hasVideo || options.drop_video}
>
<MenuItem value="copy">Copy (original)</MenuItem>
{filteredVideoCodecs.map((c) => (
<MenuItem key={c.name} value={c.name}>
{c.name.toUpperCase()}
</MenuItem>
))}
</TextField>
{/* Video Quality */}
<TextField
label="Video Quality"
type="number"
value={options.video_quality ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseInt(e.target.value, 10)
handleChange('video_quality', val)
}}
fullWidth
size="small"
disabled={!hasVideo || options.drop_video}
helperText="Optional quality value (encoder-specific; e.g., 1-31 for libx264, 0-10 for Theora)"
InputProps={{
inputProps: { min: 0, max: 51, step: 1 },
}}
/>
{/* Drop video (if video present) */}
{hasVideo && ( {hasVideo && (
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
checked={options.drop_video} checked={options.drop_video}
onChange={handleDropVideoChange} onChange={(e) => handleChange('drop_video', e.target.checked)}
/> />
} }
label="Drop video streams" label="Drop video streams"
/> />
)} )}
{/* Drop subtitles */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -172,7 +234,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Filename Settings */} {/* ------------------------------------------------------------------------
Filename Settings
------------------------------------------------------------------------ */}
<Section title="Filename Settings"> <Section title="Filename Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -228,7 +292,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Metadata Settings */} {/* ------------------------------------------------------------------------
Metadata Settings
------------------------------------------------------------------------ */}
<Section title="Metadata Settings"> <Section title="Metadata Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -259,7 +325,7 @@ export const OptionsPanel: React.FC = () => {
type="number" type="number"
value={options.comment_stream ?? ''} value={options.comment_stream ?? ''}
onChange={(e) => onChange={(e) =>
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value)) handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value, 10))
} }
size="small" size="small"
disabled={options.no_comment} disabled={options.no_comment}
@@ -284,7 +350,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Tracklist Settings */} {/* ------------------------------------------------------------------------
Tracklist Settings
------------------------------------------------------------------------ */}
<Section title="Tracklist Settings" defaultExpanded> <Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -299,9 +367,4 @@ export const OptionsPanel: React.FC = () => {
</Section> </Section>
</Paper> </Paper>
) )
// Helper function for option updates
function handleChange(field: string, value: any) {
setOptions({ [field]: value })
}
} }
+41 -12
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Box, Paper, TextField, Typography, Alert } from '@mui/material' import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore' import { useTracklistStore } from '../stores/tracklistStore'
@@ -10,6 +10,9 @@ export const TracklistEditor: React.FC = () => {
const { options } = useOptionsStore() const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const lineNumbersRef = useRef<HTMLDivElement>(null)
const validate = (text: string) => { const validate = (text: string) => {
const result = parseAndValidateTracklist(text, options.tracklist_format) const result = parseAndValidateTracklist(text, options.tracklist_format)
setEntries(result.entries) setEntries(result.entries)
@@ -23,10 +26,29 @@ export const TracklistEditor: React.FC = () => {
validate(text) validate(text)
} }
const handleScroll = useCallback(() => {
if (lineNumbersRef.current && textAreaRef.current) {
lineNumbersRef.current.scrollTop = textAreaRef.current.scrollTop
}
}, [])
useEffect(() => {
const textArea = textAreaRef.current
if (textArea) {
textArea.addEventListener('scroll', handleScroll)
return () => textArea.removeEventListener('scroll', handleScroll)
}
}, [handleScroll])
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [options.tracklist_format])
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[]) => { (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return if (acceptedFiles.length === 0) return
const file = acceptedFiles[0] const file = acceptedFiles[0]
const reader = new FileReader() const reader = new FileReader()
reader.onload = (event) => { reader.onload = (event) => {
@@ -48,13 +70,6 @@ export const TracklistEditor: React.FC = () => {
multiple: false, multiple: false,
}) })
// Re-validate when tracklist format changes
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [options.tracklist_format])
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
return ( return (
@@ -82,12 +97,13 @@ export const TracklistEditor: React.FC = () => {
</Typography> </Typography>
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 2 }}> <Box sx={{ display: 'flex', gap: 2, position: 'relative' }}>
{/* Line numbers column */} {/* Line numbers column */}
<Box <Box
ref={lineNumbersRef}
sx={{ sx={{
minWidth: 40, minWidth: 40,
maxWidth: 40, maxWidth: 60, // Allow more space for 3-digit numbers
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '14px', fontSize: '14px',
lineHeight: 1.7, lineHeight: 1.7,
@@ -95,6 +111,11 @@ export const TracklistEditor: React.FC = () => {
textAlign: 'right', textAlign: 'right',
userSelect: 'none', userSelect: 'none',
overflow: 'hidden', overflow: 'hidden',
paddingTop: '8.5px',
paddingBottom: '8.5px',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
whiteSpace: 'nowrap', // Prevent wrapping of line numbers
}} }}
> >
{rawText.split('\n').map((_, i) => ( {rawText.split('\n').map((_, i) => (
@@ -102,7 +123,7 @@ export const TracklistEditor: React.FC = () => {
))} ))}
</Box> </Box>
{/* Editor text area */} {/* Editor text area now with horizontal scroll and no wrap */}
<TextField <TextField
multiline multiline
fullWidth fullWidth
@@ -112,11 +133,19 @@ export const TracklistEditor: React.FC = () => {
onChange={handleTextChange} onChange={handleTextChange}
placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`} placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
variant="outlined" variant="outlined"
inputRef={textAreaRef}
sx={{ sx={{
'& .MuiInputBase-root': { '& .MuiInputBase-root': {
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '14px', fontSize: '14px',
lineHeight: 1.7, lineHeight: 1.7,
overflowX: 'auto', // Enable horizontal scroll
},
'& .MuiInputBase-input': {
paddingTop: '8.5px',
paddingBottom: '8.5px',
whiteSpace: 'nowrap', // Prevent wrapping
overflowX: 'auto',
}, },
}} }}
error={!isValid && errors.length > 0} error={!isValid && errors.length > 0}
+17 -5
View File
@@ -3,10 +3,11 @@ import { useDropzone } from 'react-dropzone'
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material' import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
import { CloudUpload, InsertDriveFile } from '@mui/icons-material' import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { uploadFile, getTaskInfo } from '../api/client' import { useOptionsStore } from '../stores/optionsStore' // 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 {
@@ -30,6 +31,7 @@ export const UploadZone: React.FC = () => {
} = useUploadStore() } = useUploadStore()
const { setTaskId: setTaskIdStore } = useTaskStore() const { setTaskId: setTaskIdStore } = useTaskStore()
const { setOptions } = useOptionsStore() // NEW
const onDrop = useCallback( const onDrop = useCallback(
async (acceptedFiles: File[]) => { async (acceptedFiles: File[]) => {
@@ -65,9 +67,19 @@ export const UploadZone: React.FC = () => {
setHasAudio(info.has_audio) setHasAudio(info.has_audio)
setHasSubtitle(info.has_subtitle) setHasSubtitle(info.has_subtitle)
setAudioCodec(info.audio_codec) setAudioCodec(info.audio_codec)
// Fetch recommended format and update options
try {
// Inside UploadZone.tsx, after fetching recommended format:
const rec = await getRecommendedFormat(taskId)
// Update options store with container (not format)
setOptions({ container: rec.format })
} catch (err) {
console.warn('Failed to fetch recommended format, using default', err)
}
} catch (err) { } catch (err) {
console.error('Failed to fetch stream info:', err) console.error('Failed to fetch stream info:', err)
// Don't block the upload flow if this fails; we'll just assume no video // Don't block upload flow; user can manually change options
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Upload failed') setError(err.response?.data?.detail || err.message || 'Upload failed')
@@ -78,13 +90,13 @@ export const UploadZone: React.FC = () => {
setFileSize(0) setFileSize(0)
} }
}, },
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore] [setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore, setHasVideo, setHasAudio, setHasSubtitle, setAudioCodec, setOptions]
) )
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,
+31
View File
@@ -0,0 +1,31 @@
import { useMemo } from 'react'
import { CodecInfo } from '../types'
export interface CompatibilityEntry {
audio: string[] | null
video: string[] | null
}
export function useFilterCodecs(
compatibility: Record<string, CompatibilityEntry>,
container: string | null,
codecs: CodecInfo[]
) {
const filteredAudioCodecs = useMemo(() => {
const entry = compatibility?.[container || '']
if (!entry) return codecs.filter(c => c.codec_type === 'audio')
const audioList = entry.audio
if (audioList === null) return codecs.filter(c => c.codec_type === 'audio')
return codecs.filter(c => c.codec_type === 'audio' && audioList.includes(c.name))
}, [compatibility, container, codecs])
const filteredVideoCodecs = useMemo(() => {
const entry = compatibility?.[container || '']
if (!entry) return codecs.filter(c => c.codec_type === 'video')
const videoList = entry.video
if (videoList === null) return codecs.filter(c => c.codec_type === 'video')
return codecs.filter(c => c.codec_type === 'video' && videoList.includes(c.name))
}, [compatibility, container, codecs])
return { filteredAudioCodecs, filteredVideoCodecs }
}
@@ -0,0 +1,56 @@
import { useEffect } from 'react'
import { ContainerInfo } from '../types'
import { CompatibilityEntry } from './useFilterCodecs'
export function useFormatValidation(
options: {
container: string | null
audio_codec: string
video_codec: string
drop_video: boolean
},
hasVideo: boolean,
compatibility: Record<string, CompatibilityEntry>,
containers: ContainerInfo[],
setFormatError: (error: string | null) => void
) {
useEffect(() => {
const selectedContainer = containers.find(c => c.name === options.container)
if (!selectedContainer?.supports_video && hasVideo && !options.drop_video) {
setFormatError(
`Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.`
)
return
}
// Check audio codec compatibility
if (options.audio_codec && options.audio_codec !== 'copy' && options.container) {
const entry = compatibility?.[options.container]
if (entry) {
const audioList = entry.audio
if (audioList !== null && !audioList.includes(options.audio_codec)) {
setFormatError(
`Audio codec '${options.audio_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
// Check video codec compatibility
if (options.video_codec && options.video_codec !== 'copy' && options.container && hasVideo && !options.drop_video) {
const entry = compatibility?.[options.container]
if (entry) {
const videoList = entry.video
if (videoList !== null && !videoList.includes(options.video_codec)) {
setFormatError(
`Video codec '${options.video_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
setFormatError(null)
}, [options.container, options.audio_codec, options.video_codec, options.drop_video, hasVideo, compatibility, containers, setFormatError])
}
+60 -20
View File
@@ -1,37 +1,77 @@
import { create } from 'zustand' import { create } from 'zustand'
import { SplitOptions } from '../types' import { SplitOptions, ContainerInfo, CodecInfo } from '../types'
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_TRACKLIST_FORMAT,
} from '../constants/generated'
// We use DEFAULT_FORMAT only as a fallback; container default is null (auto-detect)
const DEFAULT_OPTIONS: SplitOptions = { const DEFAULT_OPTIONS: SplitOptions = {
format: 'mp3', container: null,
transcode_to: '', audio_codec: 'copy',
drop_video: false, video_codec: 'copy',
drop_subs: false, subtitle_codec: 'copy',
number_tracks: false, video_quality: null,
replace_bad_chars: false, drop_video: DEFAULT_DROP_VIDEO,
replacement_char: '_', drop_subs: DEFAULT_DROP_SUBS,
bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ', number_tracks: DEFAULT_NUMBER_TRACKS,
skip_existing: false, replace_bad_chars: DEFAULT_REPLACE_BAD_CHARS,
output_template: '%an-%tn.%ext', replacement_char: DEFAULT_REPLACEMENT_CHAR,
album: '', bad_chars: DEFAULT_BAD_CHARS,
comment: '', skip_existing: DEFAULT_SKIP_EXISTING,
no_comment: false, output_template: DEFAULT_OUTPUT_TEMPLATE,
comment_stream: null, album: DEFAULT_ALBUM ?? '',
merge_comments: false, comment: DEFAULT_COMMENT ?? '',
comment_separator: '; ', no_comment: DEFAULT_NO_COMMENT,
tracklist_format: '%ts %tn - %an', // NEW comment_stream: DEFAULT_COMMENT_STREAM,
merge_comments: DEFAULT_MERGE_COMMENTS,
comment_separator: DEFAULT_COMMENT_SEPARATOR,
tracklist_format: DEFAULT_TRACKLIST_FORMAT,
} }
interface OptionsState { interface OptionsState {
options: SplitOptions options: SplitOptions
setOptions: (options: Partial<SplitOptions>) => void containers: ContainerInfo[]
codecs: CodecInfo[]
compatibility: Record<string, { audio: string[] | null; video: string[] | null }>
setOptions: (newOptions: Partial<SplitOptions>) => void
setContainers: (containers: ContainerInfo[]) => void
setCodecs: (codecs: CodecInfo[]) => void
setCompatibility: (compat: Record<string, { audio: string[] | null; video: string[] | null }>) => void
reset: () => void reset: () => void
} }
export const useOptionsStore = create<OptionsState>((set) => ({ export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS }, options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
setOptions: (newOptions) => setOptions: (newOptions) =>
set((state) => ({ set((state) => ({
options: { ...state.options, ...newOptions }, options: { ...state.options, ...newOptions },
})), })),
reset: () => set({ options: { ...DEFAULT_OPTIONS } }), setContainers: (containers) => set({ containers }),
setCodecs: (codecs) => set({ codecs }),
setCompatibility: (compatibility) => set({ compatibility }),
reset: () =>
set({
options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
}),
})) }))
+33 -3
View File
@@ -22,8 +22,11 @@ export interface TaskStatus {
} }
export interface SplitOptions { export interface SplitOptions {
format: string container: string | null // null = auto-detect
transcode_to?: string audio_codec: string // 'copy' or encoder name
video_codec: string // 'copy' or encoder name
subtitle_codec: string // 'copy' or encoder name
video_quality: number | null // encoder-specific integer
drop_video: boolean drop_video: boolean
drop_subs: boolean drop_subs: boolean
number_tracks: boolean number_tracks: boolean
@@ -38,7 +41,34 @@ export interface SplitOptions {
comment_stream: number | null comment_stream: number | null
merge_comments: boolean merge_comments: boolean
comment_separator: string comment_separator: string
tracklist_format: string tracklist_format: string // for frontend parsing
}
export interface ContainerInfo {
name: string
ffmpeg: string
extension: string
supports_video: boolean
supports_subs: boolean
}
export interface CodecInfo {
name: string
ffmpeg: string
recommended_container: string
recommended_extension: string
codec_type: 'audio' | 'video' | 'subtitle'
}
export interface CompatibilityEntry {
audio: string[] | null
video: string[] | null
}
export interface FormatsResponse {
containers: ContainerInfo[]
codecs: CodecInfo[]
compatibility: Record<string, CompatibilityEntry>
} }
export interface UploadResponse { export interface UploadResponse {