1 Commits

Author SHA1 Message Date
max 8fb68e76ab feat (git): adds a new issue template
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 20m33s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 2m5s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 17s
2026-09-02 12:21:47 +05:00
30 changed files with 672 additions and 1468 deletions
+126
View File
@@ -0,0 +1,126 @@
name: "Bug Report"
description: "Report a bug or unexpected behavior to help us improve"
title: "[BUG]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
## ⚠️ Before You Begin
Please ensure you have:
- [ ] Searched existing issues to avoid duplicates
- [ ] Confirmed this is a bug, not a question or configuration problem
- type: textarea
id: description
attributes:
label: "📋 Description"
description: "Provide a clear and concise description of the bug"
placeholder: "What happened? What did you expect to happen instead?"
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: "🔁 Steps to Reproduce"
description: "Step-by-step instructions to reproduce the issue"
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: "✅ Expected Behavior"
description: "What you expected to happen"
placeholder: "A clear description of what should happen..."
validations:
required: true
- type: textarea
id: actual
attributes:
label: "❌ Actual Behavior"
description: "What actually happened"
placeholder: "Include error messages, stack traces, or unexpected outcomes..."
validations:
required: true
- type: textarea
id: logs
attributes:
label: "📄 Logs / Screenshots"
description: |
Provide relevant logs, error messages, or screenshots.
For logs, please use a pastebin and share the URL.
**Remember to remove any sensitive information (API keys, passwords, etc.).**[reference:13]
placeholder: "Paste logs here or provide a Gist URL..."
validations:
required: false
- type: markdown
attributes:
value: |
---
## 🖥️ Environment Details
Please fill out the relevant information below.
- type: input
id: version
attributes:
label: "📦 Audio Splitter Version"
description: "The version you are using (or commit reference)"
placeholder: "e.g., v1.21.7"
validations:
required: true
- type: input
id: os
attributes:
label: "💻 Operating System"
description: "Your OS and version"
placeholder: "e.g., Ubuntu 22.04, macOS Sonoma 14.5, Windows"
validations:
required: true
- type: input
id: browser
attributes:
label: "🌍 Browser (if applicable)"
description: "Browser name and version"
placeholder: "e.g., Chrome 120, Firefox 121, Safari 17"
validations:
required: false
- type: textarea
id: additional
attributes:
label: "📎 Additional Context"
description: "Any other information that might be relevant"
placeholder: |
- Database type and version (e.g., PostgreSQL 15, SQLite)
- Reverse proxy/CDN in use (e.g., Nginx, Cloudflare)[reference:15]
- Any custom configuration
- Related issues or PRs
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: "✅ Submission Checklist"
description: "Please confirm the following before submitting"
options:
- label: "I have searched for existing issues (open and closed) that report the same problem"
required: true
- label: "I am using the latest stable release of Audio Splitter"
required: true
- label: "I have provided clear steps to reproduce the issue"
required: true
- label: "I have included relevant logs or error messages (with sensitive info removed)"
required: false
+57
View File
@@ -0,0 +1,57 @@
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"]
+16 -131
View File
@@ -1,135 +1,20 @@
# audio_splitter/constants.py """Global constants and default values."""
"""Global constants for the audio splitter.
This file serves as the single source of truth for: # Mapping from user‑friendly format names to FFmpeg format identifiers,
- Container formats and their properties # file extensions, and whether the container is audio‑only.
- Audio codecs and their recommended containers
- Video codec support per container
- File extensions for each codec/container combination
- Compatibility matrix for codec/container validation
Codec != Container != File Extension.
Example: Opus (codec) → Ogg (container) → .opus (extension)
"""
# ------------------------------------------------------------------------------
# Container information
# ------------------------------------------------------------------------------
CONTAINER_INFO = [
# Audio-only containers
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
# Containers that support video and subtitles
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'matroska', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'audio_only': False, 'supports_video': True, 'supports_subs': False},
]
# Legacy FORMAT_INFO for backward compatibility
FORMAT_INFO = { FORMAT_INFO = {
container['name']: { 'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
'ffmpeg': container['ffmpeg'], 'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
'ext': container['extension'], 'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
'audio_only': container['audio_only'], 'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
} 'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
for container in CONTAINER_INFO '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},
} }
# ------------------------------------------------------------------------------ # Default characters to replace when --replace-bad-chars is enabled.
# Codec information # Includes common punctuation, single quote, and a trailing space.
# ------------------------------------------------------------------------------ DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' '
CODEC_INFO = [
# Audio codecs
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'supports_transcoding': True, 'supports_video': False},
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'supports_transcoding': True, 'supports_video': False},
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'supports_transcoding': True, 'supports_video': False},
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'supports_transcoding': True, 'supports_video': False},
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
{'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'supports_transcoding': True, 'supports_video': False},
# Video codecs
{'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'supports_transcoding': True, 'supports_video': True},
{'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True},
{'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True},
{'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'supports_transcoding': True, 'supports_video': True},
{'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True},
{'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True},
{'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'supports_transcoding': False, 'supports_video': True},
{'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'supports_transcoding': False, 'supports_video': True},
]
# Map codec name → recommended container
CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO}
CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO}
# Map codec name → FFmpeg encoder name
CODEC_NAME_TO_FFMPEG = {codec['name']: codec['ffmpeg'] for codec in CODEC_INFO}
# ------------------------------------------------------------------------------
# Video codec support per container (legacy, will be superseded by compatibility matrix)
# ------------------------------------------------------------------------------
CONTAINER_VIDEO_CODEC_SUPPORT = {
'mp4': ['h264', 'h265', 'vp9', 'av1', 'mpeg4', 'hevc'],
'mkv': ['*'],
'matroska': ['*'],
'ogg': ['theora', 'dirac', 'vp8', 'png', 'mjpeg'],
'webm': ['vp8', 'vp9', 'av1'],
'mp3': [],
'm4a': [],
'flac': [],
'wav': [],
'aac': [],
}
# ------------------------------------------------------------------------------
# Compatibility matrix: container → supported audio and video codecs
# ------------------------------------------------------------------------------
# Each container entry maps to a dict with 'audio' and 'video' keys.
# - 'audio': list of audio codec names that are supported in this container.
# Use None to indicate that any audio codec is supported.
# - 'video': list of video codec names that are supported in this container.
# Use None to indicate that any video codec is supported.
# Empty list means no video support (audio-only container).
COMPATIBILITY_MATRIX = {
'mp3': {'audio': ['mp3'], 'video': []},
'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []},
'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']},
'mkv': {'audio': None, 'video': None},
'matroska': {'audio': None, 'video': None},
'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8']},
'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']},
'flac': {'audio': ['flac'], 'video': []},
'wav': {'audio': ['pcm_s16le'], 'video': []},
'aac': {'audio': ['aac'], 'video': []},
}
# ------------------------------------------------------------------------------
# Helper functions for compatibility checking
# ------------------------------------------------------------------------------
def is_audio_codec_supported(container: str, audio_codec: str) -> bool:
"""Check if an audio codec is supported in the given container."""
entry = COMPATIBILITY_MATRIX.get(container, {})
supported = entry.get('audio')
if supported is None:
return True
return audio_codec in supported
def is_video_codec_supported(container: str, video_codec: str) -> bool:
"""Check if a video codec is supported in the given container."""
entry = COMPATIBILITY_MATRIX.get(container, {})
supported = entry.get('video')
if supported is None:
return True
return video_codec in supported
# ------------------------------------------------------------------------------
# Default bad characters (for filename sanitization)
# ------------------------------------------------------------------------------
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
+29 -146
View File
@@ -1,68 +1,30 @@
# audio_splitter/core.py """Core logic: orchestrates the splitting process."""
"""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 ( from .constants import FORMAT_INFO
FORMAT_INFO, from .ffmpeg import get_audio_duration, get_stream_info, get_metadata, build_ffmpeg_command
CODEC_TO_EXTENSION_MAP, from .formats import determine_output_format, validate_format_compatibility
CODEC_NAME_TO_FFMPEG, # <-- Add this
)
from .ffmpeg import (
get_audio_duration,
get_stream_info,
get_metadata,
build_ffmpeg_command,
is_attached_picture,
extract_cover_image,
)
from .formats import (
determine_output_format,
validate_format_compatibility,
)
from .timestamp import parse_track_timestamps, resolve_end_times from .timestamp import parse_track_timestamps, resolve_end_times
from .filename import build_filename from .filename import build_filename
from .metadata import build_metadata_dict from .metadata import build_metadata_dict
from .utils import format_time from .utils import format_time
def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, Any]], args) -> None: def split_audio(input_file, output_directory, tracks, args):
""" """
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 (ts, tn, an, ...). tracks: List of dicts, each containing parsed fields.
args: Parsed command‑line arguments (namespace) with attributes: args: Parsed command‑line arguments (namespace).
- container: output container name (or None for auto-detect)
- audio_codec: audio codec (copy or encoder)
- video_codec: video codec (copy or encoder)
- subtitle_codec: subtitle codec (copy or encoder)
- video_quality: integer or None
- drop_video: bool
- drop_subs: bool
- number_tracks: bool
- output_template: str
- replace_bad_chars: bool
- replacement_char: str
- bad_chars: str
- skip_existing: bool
- album: str or None
- comment: str or None
- no_comment: bool
- comment_stream: int or None
- merge_comments: bool
- comment_separator: str
- delete_original: bool
- cover_image: str or None (optional, set by web backend)
Raises: Raises:
RuntimeError: If no audio stream is found. RuntimeError: If no audio stream is found.
ValueError: If compatibility validation fails. ValueError: If timestamp parsing or format compatibility fails.
""" """
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 1. Setup # 1. Setup
@@ -80,58 +42,26 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
raise RuntimeError("No audio stream found in input file.") raise RuntimeError("No audio stream found in input file.")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 2. Determine output container # 2. Format decision
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
user_container = getattr(args, 'container', None) output_format = determine_output_format(stream_info, args.format, args.transcode_to, input_file=input_file)
output_container = determine_output_format( print(f"Output container: {output_format}")
stream_info,
user_format=user_container, validate_format_compatibility(output_format, stream_info,
transcode_audio=args.audio_codec if args.audio_codec != 'copy' else None, args.drop_video, args.drop_subs)
input_file=input_file
) # Determine file extension.
print(f"Output container: {output_container}") extension_info = FORMAT_INFO.get(output_format, {})
extension = extension_info.get('ext', '.mkv')
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 3. Validate compatibility # 3. Parse timestamps
# --------------------------------------------------------------------------
try:
validate_format_compatibility(
container=output_container,
stream_info=stream_info,
drop_video=args.drop_video,
drop_subs=args.drop_subs,
input_file=input_file,
audio_codec=args.audio_codec,
video_codec=args.video_codec,
)
except ValueError as e:
raise RuntimeError(f"Compatibility error: {e}")
# --------------------------------------------------------------------------
# 4. Determine output audio codec and extension
# --------------------------------------------------------------------------
# Determine the audio codec that will be used in the output
if args.audio_codec != 'copy':
output_audio_codec = args.audio_codec
else:
output_audio_codec = stream_info.get('audio_codec', '')
# Choose extension based on audio codec if possible, otherwise fallback to container default
if output_audio_codec in CODEC_TO_EXTENSION_MAP:
extension = CODEC_TO_EXTENSION_MAP[output_audio_codec]
else:
extension = FORMAT_INFO.get(output_container, {}).get('ext', '.mkv')
print(f"Output extension: {extension}")
# --------------------------------------------------------------------------
# 5. Parse timestamps
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
track_times = parse_track_timestamps(tracks) track_times = parse_track_timestamps(tracks)
resolved_times = resolve_end_times(track_times, total_duration) resolved_times = resolve_end_times(track_times, total_duration)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 6. Fetch original metadata (for fallbacks) # 4. 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')
@@ -148,22 +78,7 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
print(f" Stream {idx}: '{comment}'") print(f" Stream {idx}: '{comment}'")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 7. Handle attached picture (cover art) # 5. Process each track
# --------------------------------------------------------------------------
cover_image_path = getattr(args, 'cover_image', None)
if cover_image_path and not os.path.exists(cover_image_path):
cover_image_path = None
if not cover_image_path and not args.drop_video and stream_info.get('has_video'):
if is_attached_picture(input_file):
cover_image_path = os.path.join(output_directory, 'cover.png')
if extract_cover_image(input_file, cover_image_path):
print("Extracted cover image for all tracks.")
else:
cover_image_path = None
# --------------------------------------------------------------------------
# 8. Process each track
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
for idx, track in enumerate(tracks, start=1): for idx, track in enumerate(tracks, start=1):
start_seconds, end_seconds = resolved_times[idx - 1] start_seconds, end_seconds = resolved_times[idx - 1]
@@ -176,53 +91,31 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
track_name = track.get('tn', 'Unknown') track_name = track.get('tn', 'Unknown')
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 8a. Build filename # 5a. 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)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 8b. Handle existing files # 5b. 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
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 8c. Build metadata # 5c. Build metadata
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
metadata = build_metadata_dict(track, idx, input_metadata, args) metadata = build_metadata_dict(track, idx, input_metadata, args)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 8d. Build and execute FFmpeg command # 5d. Build and execute FFmpeg command
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Map codec names to FFmpeg encoder names
audio_enc = args.audio_codec
if audio_enc != 'copy':
audio_enc = CODEC_NAME_TO_FFMPEG.get(audio_enc, audio_enc) # fallback to itself if not found
video_enc = args.video_codec
if video_enc != 'copy':
video_enc = CODEC_NAME_TO_FFMPEG.get(video_enc, video_enc)
subtitle_enc = args.subtitle_codec
if subtitle_enc != 'copy':
subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc)
# Then build command with these mapped encoders
cmd = build_ffmpeg_command( cmd = build_ffmpeg_command(
input_file=input_file, input_file, start_seconds, duration_seconds, output_path,
start_seconds=start_seconds, stream_info, output_format, args.transcode_to,
duration_seconds=duration_seconds, args.drop_video, args.drop_subs,
output_path=output_path, metadata=metadata
stream_info=stream_info,
format_opt=output_container,
audio_codec=audio_enc,
video_codec=video_enc,
subtitle_codec=subtitle_enc,
metadata=metadata,
cover_image_path=cover_image_path,
video_quality=getattr(args, 'video_quality', None),
drop_video=args.drop_video,
drop_subs=args.drop_subs,
) )
print(f"Extracting track {idx}: {track_name} " print(f"Extracting track {idx}: {track_name} "
@@ -235,13 +128,3 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A
print(result.stderr) print(result.stderr)
else: else:
print(f" -> Saved to: {output_path}") print(f" -> Saved to: {output_path}")
# --------------------------------------------------------------------------
# 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
@@ -1,35 +0,0 @@
"""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
+51 -193
View File
@@ -1,5 +1,4 @@
# audio_splitter/ffmpeg.py """FFmpeg/FFprobe interaction utilities for the CLI and web backend."""
"""FFmpeg/FFprobe interaction utilities."""
import json import json
import subprocess import subprocess
@@ -76,28 +75,6 @@ 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.
@@ -179,112 +156,33 @@ def get_container_format(input_file: str) -> Optional[str]:
format_name = result.stdout.strip().split(',')[0] # take first if multiple format_name = result.stdout.strip().split(',')[0] # take first if multiple
if not format_name: if not format_name:
return None return None
# Normalize common aliases to names used in FORMAT_INFO # Normalize common aliases to names used in FORMAT_INFO (container only, not codec-specific)
# This mapping is purely for container identification.
mapping = { mapping = {
'mpeg': 'mp3', 'mpeg': 'mp3', # MPEG-1/2 audio (MP3) container
'mp2': 'mp3', 'mp2': 'mp3',
'mp4': 'mp4', 'mp4': 'mp4',
'm4a': 'mp4', 'm4a': 'mp4', # M4A is MP4 container
'mov': 'mp4', 'mov': 'mp4', # QuickTime is MP4-like
'3gp': 'mp4', '3gp': 'mp4',
'matroska': 'matroska', 'matroska': 'matroska',
'webm': 'matroska', 'webm': 'matroska', # WebM uses Matroska container
'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', 'adts': 'aac', # raw AAC in ADTS container
'amr': 'amr', 'amr': 'amr', # AMR container (rare)
} }
return mapping.get(format_name, format_name) return mapping.get(format_name, format_name)
def is_attached_picture(input_file: str) -> bool: def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: int,
""" output_path: str, stream_info: Dict, format_opt: Optional[str],
Check if the input file has a video stream that is an attached picture (cover art). transcode_audio: Optional[str], drop_video: bool, drop_subs: bool,
metadata: Optional[Dict] = None) -> List[str]:
Detection logic:
1. If there is a video stream with disposition.attached_pic == 1, return True.
2. Otherwise, if there is exactly one video stream and its codec is an image
format (PNG, MJPEG, JPEG, GIF, BMP), return True.
Returns:
True if a cover image is detected, False otherwise.
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-select_streams', 'v',
'-show_entries', 'stream=codec_name,disposition',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return False
try:
data = json.loads(result.stdout)
streams = data.get('streams', [])
if not streams:
return False
# Check each stream
for stream in streams:
codec = stream.get('codec_name', '').lower()
disposition = stream.get('disposition', {})
# If attached_pic is set, it's a cover image
if disposition.get('attached_pic') == 1:
return True
# If not, check if it's an image codec and we have exactly one video stream
if codec in ('png', 'mjpeg', 'jpeg', 'gif', 'bmp'):
# If there is exactly one video stream, treat it as cover
if len(streams) == 1:
return True
return False
except (json.JSONDecodeError, KeyError):
return False
def extract_cover_image(input_file: str, output_path: str) -> bool:
"""
Extract the first frame of the video stream (assumed to be an attached picture)
and save it to output_path.
Returns:
True if extraction succeeded, False otherwise.
"""
cmd = [
'ffmpeg', '-i', input_file,
'-map', '0:v:0',
'-frames:v', '1',
'-y',
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"Failed to extract cover image: {result.stderr}")
return False
return True
def build_ffmpeg_command(
input_file: str,
start_seconds: int,
duration_seconds: int,
output_path: str,
stream_info: Dict,
format_opt: Optional[str],
audio_codec: Optional[str] = 'copy',
video_codec: Optional[str] = 'copy',
subtitle_codec: Optional[str] = 'copy',
metadata: Optional[Dict] = None,
cover_image_path: Optional[str] = None,
video_quality: Optional[int] = None,
drop_video: bool = False,
drop_subs: bool = False,
) -> List[str]:
""" """
Construct the FFmpeg command line as a list of arguments. Construct the FFmpeg command line as a list of arguments.
@@ -294,30 +192,21 @@ def build_ffmpeg_command(
duration_seconds: Duration of the segment (in seconds). duration_seconds: Duration of the segment (in seconds).
output_path: Destination path for the output file. output_path: Destination path for the output file.
stream_info: Dictionary from get_stream_info(). stream_info: Dictionary from get_stream_info().
format_opt: Output container format (e.g., 'mp3', 'mkv'). format_opt: Output container format (e.g., 'mp3').
audio_codec: Audio codec to use ('copy' or encoder name like 'libopus'). transcode_audio: Audio codec to transcode to (or None).
video_codec: Video codec to use ('copy' or encoder name). drop_video: True to remove video streams.
subtitle_codec: Subtitle codec to use ('copy' or encoder name). drop_subs: True to remove subtitle streams.
metadata: Optional dict of metadata key/value pairs to write. metadata: Optional dict of metadata key/value pairs to write.
cover_image_path: Path to extracted cover image (if any).
video_quality: Quality value for video encoder (e.g., 1-31, lower=better).
drop_video: If True, remove video streams.
drop_subs: If True, remove subtitle streams.
Returns: Returns:
A list of command‑line arguments suitable for subprocess.run(). A list of command‑line arguments suitable for subprocess.run().
""" """
cmd = ['ffmpeg'] cmd = [
'ffmpeg',
# Add cover image as first input if provided '-i', input_file,
if cover_image_path: '-ss', format_time(start_seconds),
cmd.extend(['-i', cover_image_path]) '-t', format_time(duration_seconds)
]
# Add main input file
cmd.extend(['-i', input_file])
# Time options
cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)])
# Clear all original metadata. # Clear all original metadata.
cmd.append('-map_metadata') cmd.append('-map_metadata')
@@ -329,70 +218,39 @@ def build_ffmpeg_command(
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 and codecs ---------- # Stream mapping.
if cover_image_path: if drop_video and drop_subs:
# We have two inputs: index 0 = cover image, index 1 = main input cmd.extend(['-map', '0:a:0'])
# Map audio from main input (index 1) and video from cover image (index 0) elif drop_video:
cmd.extend(['-map', '1:a:0', '-map', '0:v:0']) cmd.extend(['-map', '0:a:0', '-map', '0:s?'])
elif drop_subs:
cmd.extend(['-map', '0:a:0', '-map', '0:v:0'])
else:
cmd.extend(['-map', '0'])
# Video codec: use user-specified codec if provided, otherwise fallback to png # Audio codec.
if video_codec and video_codec != 'copy': if transcode_audio:
cmd.extend(['-c:v', video_codec]) cmd.extend(['-c:a', transcode_audio])
else: if transcode_audio in ('libmp3lame', 'mp3'):
cmd.extend(['-c:v', 'png']) cmd.extend(['-b:a', '192k'])
elif transcode_audio in ('libopus', 'opus'):
cmd.extend(['-b:a', '128k'])
else:
cmd.extend(['-c:a', 'copy'])
# Audio codec # Video codec.
if audio_codec and audio_codec != 'copy': if not drop_video and stream_info['has_video']:
cmd.extend(['-c:a', audio_codec]) cmd.extend(['-c:v', 'copy'])
else: else:
cmd.extend(['-c:a', 'copy']) cmd.append('-vn')
# Subtitle: we don't copy subtitles when using cover image (they would be from main input) # Subtitle codec.
if not drop_subs and stream_info['has_subtitle']:
cmd.extend(['-c:s', 'copy'])
else:
cmd.append('-sn') cmd.append('-sn')
# Video quality if specified # Output format.
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
else:
# Standard mapping (no cover image)
if drop_video and drop_subs:
cmd.extend(['-map', '0:a:0'])
elif drop_video:
cmd.extend(['-map', '0:a:0', '-map', '0:s?'])
elif drop_subs:
cmd.extend(['-map', '0:a:0', '-map', '0:v:0'])
else:
cmd.extend(['-map', '0'])
# Audio codec
if audio_codec and audio_codec != 'copy':
cmd.extend(['-c:a', audio_codec])
else:
cmd.extend(['-c:a', 'copy'])
# Video codec
if not drop_video and stream_info.get('has_video'):
if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', video_codec])
# Add video quality if specified (only when re-encoding)
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
else:
cmd.extend(['-c:v', 'copy'])
else:
cmd.append('-vn')
# Subtitle codec
if not drop_subs and stream_info.get('has_subtitle'):
if subtitle_codec and subtitle_codec != 'copy':
cmd.extend(['-c:s', subtitle_codec])
else:
cmd.extend(['-c:s', 'copy'])
else:
cmd.append('-sn')
# Output format
if format_opt: if format_opt:
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt) ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
cmd.extend(['-f', ffmpeg_format]) cmd.extend(['-f', ffmpeg_format])
+63 -60
View File
@@ -1,25 +1,16 @@
# 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 ( from .constants import FORMAT_INFO
FORMAT_INFO,
CONTAINER_INFO,
CODEC_TO_CONTAINER_MAP,
COMPATIBILITY_MATRIX,
is_audio_codec_supported,
is_video_codec_supported,
)
from .defaults import DEFAULT_FORMAT
def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]: def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
""" """
Given the container format and audio codec, determine the recommended output format. Given the container format and audio codec, determine the recommended output format.
Uses the CODEC_TO_CONTAINER_MAP to map codec → container. This is used when the user has not explicitly specified a format.
If the codec is not found, it falls back to the container. It prioritizes the codec to choose the most appropriate container/extension.
Args: Args:
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format(). container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format().
@@ -31,13 +22,37 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) ->
if not container: if not container:
return None return None
if codec and codec in CODEC_TO_CONTAINER_MAP: # Codec-based decisions (highest priority)
fmt = CODEC_TO_CONTAINER_MAP[codec] if codec == 'opus':
if fmt in FORMAT_INFO: return 'opus'
return fmt if codec in ('aac', 'alac', 'he-aac'):
return 'm4a'
if codec == 'mp3':
return 'mp3'
if codec == 'vorbis':
return 'ogg'
if codec == 'flac':
return 'flac'
if container in FORMAT_INFO: # Container-based fallback (lower priority)
return container if container in ('mp4', 'm4a', 'mov', '3gp'):
return 'mp4'
if container in ('matroska', 'webm'):
return 'matroska'
if container in ('ogg',):
return 'ogg'
if container in ('mp3', 'mpeg'):
return 'mp3'
if container == 'flac':
return 'flac'
if container == 'wav':
return 'wav'
if container == 'aac':
return 'aac'
if container == 'opus':
return 'opus'
if container == 'amr':
return 'amr'
return None return None
@@ -53,75 +68,63 @@ def determine_output_format(stream_info: Dict, user_format: Optional[str],
- MKV if video/subtitles exist - MKV if video/subtitles exist
- MP3 if the audio codec is MP3 - MP3 if the audio codec is MP3
- MP4 (M4A) otherwise - MP4 (M4A) otherwise
Args:
stream_info: Dict from get_stream_info().
user_format: User‑requested format (or None).
transcode_audio: Audio codec to transcode to (or None) (unused in this function).
input_file: Path to the input file (optional, used to detect container and codec).
Returns:
A format name that exists in FORMAT_INFO.
""" """
if user_format: if user_format:
return user_format return user_format
# If input_file is provided, try to detect container and codec
if input_file: if input_file:
try: try:
from .ffmpeg import get_container_format, get_audio_codec from .ffmpeg import get_container_format, get_audio_codec
container = get_container_format(input_file) container = get_container_format(input_file)
audio_codec = get_audio_codec(input_file) codec = get_audio_codec(input_file)
fmt = determine_default_format(container, audio_codec) fmt = determine_default_format(container, codec)
if fmt in FORMAT_INFO: if fmt in FORMAT_INFO:
return fmt return fmt
except Exception: except Exception:
# If detection fails, fall through to legacy logic
pass pass
# Fallback # Fallback: legacy behavior
if stream_info.get('has_video') or stream_info.get('has_subtitle'): if stream_info.get('has_video') or stream_info.get('has_subtitle'):
return 'matroska' return 'matroska'
audio_codec = stream_info.get('audio_codec', '') audio_codec = stream_info.get('audio_codec', '')
if audio_codec == 'mp3': if audio_codec == 'mp3':
return 'mp3' return 'mp3'
else: else:
return 'mp4' return 'mp4' # .m4a
def validate_format_compatibility( def validate_format_compatibility(format_name: str, stream_info: Dict,
container: str, drop_video: bool, drop_subs: bool) -> None:
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 and codec combination is valid. Ensure the chosen container can accommodate the streams we intend to keep.
Raises: Raises:
ValueError: If the combination is incompatible. ValueError: If the format is incompatible with the intended streams.
""" """
info = FORMAT_INFO.get(container) info = FORMAT_INFO.get(format_name)
if not info: if not info:
print(f"Warning: Unknown container '{container}'. Proceeding, but may fail.") print(f"Warning: Unknown format '{format_name}'. Proceeding, but may fail.")
return return
# Check if container is audio-only and video is present (unless dropped) if info['audio_only']:
if info['audio_only'] and stream_info.get('has_video') and not drop_video: if stream_info.get('has_video') and not drop_video:
raise ValueError(
f"Container '{container}' does not support video streams. "
"Please use --drop-video or choose a container that supports video (e.g., MKV, MP4)."
)
# Check if audio codec is supported
if audio_codec and audio_codec != 'copy':
if not is_audio_codec_supported(container, audio_codec):
raise ValueError( raise ValueError(
f"Container '{container}' does not support audio codec '{audio_codec}'. " f"Format '{format_name}' does not support video streams. "
f"Please choose a different container or audio codec." "Please use --drop-video or choose a container that supports video."
)
if stream_info.get('has_subtitle') and not drop_subs:
raise ValueError(
f"Format '{format_name}' does not support subtitle streams. "
"Please use --drop-subs or choose a container that supports subtitles."
) )
# Check if video codec is supported (if video is present and not dropped)
if stream_info.get('has_video') and not drop_video:
# Determine the video codec from input file if not provided
if video_codec is None and input_file:
from .ffmpeg import get_video_codec
video_codec = get_video_codec(input_file)
if video_codec and video_codec != 'copy':
if not is_video_codec_supported(container, video_codec):
raise ValueError(
f"Container '{container}' does not support video codec '{video_codec}'. "
f"Please choose a different container, drop video, or transcode video to a supported codec."
)
+100 -125
View File
@@ -1,182 +1,139 @@
#!/usr/bin/env python3 """Command‑line interface and entry point."""
"""
Audio Splitter – Command‑Line 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 .defaults import ( from .constants import DEFAULT_BAD_CHARS
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 .core import split_audio
from .tracklist import read_tracklist, parse_format from .tracklist import read_tracklist, parse_format
from .core import split_audio
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 (or custom with --tracklist-format)" epilog="Tracklist format: mm:ss track_name - author_name"
) )
# Positional # Positional arguments.
parser.add_argument('input_file', help='Input audio file') parser.add_argument('input_file', help='Input audio/video file')
parser.add_argument('tracklist_file', help='Tracklist file') parser.add_argument('tracklist_file', help='Tracklist file')
# Container and codec options # Optional arguments.
parser.add_argument( parser.add_argument(
'--container', '--output-dir', '-o',
default=None, help='Output directory for split tracks (default: <input_basename>_splits)'
help="Output container format (auto-detect if not specified)"
) )
parser.add_argument( parser.add_argument(
'--audio-codec', '--format',
default='copy', help='Output container format (e.g., mp3, m4a, mkv, mp4, ogg, opus)'
help="Audio codec (copy or encoder name, e.g., libopus)"
) )
parser.add_argument( parser.add_argument(
'--video-codec', '--transcode-to',
default='copy', help='Re-encode audio to this codec (e.g., libmp3lame, aac, libopus)'
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',
default=DEFAULT_DROP_VIDEO, help='Remove video streams from output'
help="Drop video streams"
) )
parser.add_argument( parser.add_argument(
'--drop-subs', '--drop-subs',
action='store_true', action='store_true',
default=DEFAULT_DROP_SUBS, help='Remove subtitle streams from output'
help="Drop subtitle streams"
) )
# Filename options
parser.add_argument( parser.add_argument(
'--number-tracks', '--number-tracks',
action='store_true', action='store_true',
default=DEFAULT_NUMBER_TRACKS, help='Prepend track number to output filenames (convenience; use %%num in template for full control)'
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',
default=DEFAULT_REPLACE_BAD_CHARS, help='Replace problematic characters in filenames (default: off)'
help="Replace bad characters"
) )
parser.add_argument( parser.add_argument(
'--replacement-char', '--replacement-char',
default=DEFAULT_REPLACEMENT_CHAR, default='_',
help="Replacement character (default: %(default)s)" help='Character used as replacement (default: "_")'
) )
parser.add_argument( parser.add_argument(
'--bad-chars', '--bad-chars',
default=DEFAULT_BAD_CHARS, default=DEFAULT_BAD_CHARS,
help="Bad characters to replace (default: %(default)s)" help='String of characters to replace (default includes space and single quote)'
) )
parser.add_argument( parser.add_argument(
'--skip-existing', '--skip-existing',
action='store_true', action='store_true',
default=DEFAULT_SKIP_EXISTING, help='Skip extraction if output file already exists (default: overwrite)'
help="Skip existing output files"
) )
# Metadata
parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name")
parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment")
parser.add_argument(
'--no-comment',
action='store_true',
default=DEFAULT_NO_COMMENT,
help="Ignore comment"
)
parser.add_argument(
'--comment-stream',
type=int,
default=DEFAULT_COMMENT_STREAM,
help="Comment stream index"
)
parser.add_argument(
'--merge-comments',
action='store_true',
default=DEFAULT_MERGE_COMMENTS,
help="Merge all comments"
)
parser.add_argument(
'--comment-separator',
default=DEFAULT_COMMENT_SEPARATOR,
help="Separator for merged comments (default: %(default)s)"
)
# Tracklist format
parser.add_argument( parser.add_argument(
'--tracklist-format', '--tracklist-format',
default=DEFAULT_TRACKLIST_FORMAT, default='%ts %tn - %an',
help="Tracklist format (default: %(default)s)" 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"'
) )
# Other
parser.add_argument( parser.add_argument(
'--delete-original', '--output-template',
action='store_true', default='%an-%tn.%ext',
default=DEFAULT_DELETE_ORIGINAL, help='Template for output filenames using placeholders: '
help="Delete original file after split" '%%tn (track name), %%an (author), %%al (album), '
'%%date (date/year), %%ext (file extension), %%num (track number). '
'Default: "%%an-%%tn.%%ext"'
) )
parser.add_argument( parser.add_argument(
'--dry-run', '--dry-run',
action='store_true', action='store_true',
help="Parse and display tracklist without splitting" help='Parse and display the tracklist without splitting any files'
)
# Metadata options.
parser.add_argument(
'--album',
help='Set album name in output metadata (overrides parsed %%al and original album)'
) )
parser.add_argument( parser.add_argument(
'--output-dir', '-o', '--comment',
help='Explicit comment text (overrides all other comment settings)'
)
parser.add_argument(
'--no-comment',
action='store_true',
help='Explicitly ignore any comment (no comment written)'
)
parser.add_argument(
'--comment-stream',
type=int,
default=None, default=None,
help="Output directory (default: <input_basename>_splits)" help='Select comment from a specific stream index (0‑based). Default: first stream with a comment.'
)
parser.add_argument(
'--merge-comments',
action='store_true',
help='Merge all comments from all streams into one (separated by --comment-separator)'
)
parser.add_argument(
'--comment-separator',
default='; ',
help='Separator used when merging comments (default: "; ")'
)
# NEW: Delete original after successful split.
parser.add_argument(
'--delete-original',
action='store_true',
help='Delete the original input file after successful splitting (default: keep)'
) )
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)
@@ -185,7 +142,12 @@ def main():
print(f"Error: Tracklist file not found: {args.tracklist_file}") print(f"Error: Tracklist file not found: {args.tracklist_file}")
sys.exit(1) sys.exit(1)
# Check FFmpeg # Ensure the replacement character is a single character.
if len(args.replacement_char) != 1:
print("Error: --replacement-char must be a single character.")
sys.exit(1)
# Check that FFmpeg is installed.
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):
@@ -193,14 +155,14 @@ def main():
print(" - https://ffmpeg.org/download.html") print(" - https://ffmpeg.org/download.html")
sys.exit(1) sys.exit(1)
# Parse tracklist format # Parse the tracklist using the user‑provided format.
try: try:
tokens = parse_format(args.tracklist_format) tokens = parse_format(args.tracklist_format)
except ValueError as e: except ValueError as error:
print(f"Error in --tracklist-format: {e}") print(f"Error in --tracklist-format: {error}")
sys.exit(1) sys.exit(1)
# Read tracklist # Read and parse the tracklist file.
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.")
@@ -208,6 +170,7 @@ def main():
print(f"Found {len(tracks)} tracks.") print(f"Found {len(tracks)} tracks.")
# Dry‑run mode: display parsed data and exit.
if args.dry_run: if args.dry_run:
print("\nParsed tracklist:") print("\nParsed tracklist:")
print("-" * 60) print("-" * 60)
@@ -221,22 +184,34 @@ def main():
print(f"{idx:3d} | " + " | ".join(values)) print(f"{idx:3d} | " + " | ".join(values))
print("-" * 60) print("-" * 60)
print("Dry‑run complete. No files were created.") print("Dry‑run complete. No files were created.")
return sys.exit(0)
# Determine output directory # Determine the 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 split # Run the splitter.
try: try:
split_audio(args.input_file, output_dir, tracks, args) split_audio(args.input_file, output_dir, tracks, args)
except Exception as e: except (RuntimeError, ValueError) as error:
print(f"Error during split: {e}") print(f"Error: {error}")
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!")
+37
View File
@@ -0,0 +1,37 @@
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:
+5 -10
View File
@@ -1,24 +1,19 @@
services: services:
backend: backend:
build: image: git.vmn.su/max/audio_splitter_backend:latest
context: .
dockerfile: web/backend/Dockerfile
#image: max/audio_splitter_backend:latest
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:-8000}:8000"
volumes: volumes:
#- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web" # Bind mount for persistent data storage.
- "/tmp/test:/tmp/audio_splitter_web" # Set BACKEND_DATA_DIR in .env to point to your data directory.
- "${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:
build: image: git.vmn.su/max/audio_splitter_frontend:latest
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
@@ -0,0 +1,18 @@
#!/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
@@ -1,79 +0,0 @@
#!/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,3 +1,4 @@
"""API route handlers.""" """API route handlers."""
from . import upload, split, split_file, status, download, websocket, formats, info from . import upload, split, status, download
# websocket is imported directly in main.py to avoid circular import
+11 -5
View File
@@ -1,9 +1,8 @@
# 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 audio_splitter.constants import CONTAINER_INFO, CODEC_INFO from backend.constants import FORMAT_INFO
router = APIRouter(prefix="/api", tags=["formats"]) router = APIRouter(prefix="/api", tags=["formats"])
@@ -11,9 +10,16 @@ router = APIRouter(prefix="/api", tags=["formats"])
@router.get("/formats") @router.get("/formats")
async def get_formats(): async def get_formats():
""" """
Return the list of supported containers and codecs. Return the list of supported container formats with their properties.
""" """
return { return {
"containers": CONTAINER_INFO, "formats": [
"codecs": CODEC_INFO, {
"name": name,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
} }
+2 -39
View File
@@ -5,12 +5,7 @@ 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 FORMAT_INFO
from audio_splitter.defaults import DEFAULT_FORMAT
router = APIRouter(prefix="/api", tags=["info"]) router = APIRouter(prefix="/api", tags=["info"])
@@ -18,8 +13,7 @@ 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, audio_codec) Return stream information (has_audio, has_video, has_subtitle) for the uploaded file.
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")
@@ -39,34 +33,3 @@ 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 FORMAT_INFO:
fmt = "mp3" # ultimate fallback
return {"format": fmt}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to determine format: {str(e)}")
+3 -31
View File
@@ -1,4 +1,4 @@
"""Split task endpoint (JSON tracklist).""" """Split task endpoint."""
from fastapi import APIRouter, HTTPException, BackgroundTasks from fastapi import APIRouter, HTTPException, BackgroundTasks
@@ -24,29 +24,6 @@ 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,
@@ -54,14 +31,9 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
message="Preparing to split..." message="Preparing to split..."
) )
background_tasks.add_task( background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options)
run_split_task,
task_id,
request.tracklist,
options
)
return SplitResponse( return SplitResponse(
task_id=task_id, task_id=task_id,
status=TaskStatus.PROCESSING status=TaskStatus.PROCESSING
) )
-151
View File
@@ -1,151 +0,0 @@
"""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
)
+3 -4
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, split_file, status, download, websocket, formats, info from backend.api import upload, split, status, download, websocket, formats, info
from backend.services import progress_publisher from backend.services import progress_publisher
app = FastAPI( app = FastAPI(
@@ -33,12 +33,11 @@ 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) app.include_router(formats.router) # new
app.include_router(info.router) app.include_router(info.router) # new
@app.get("/") @app.get("/")
+5 -27
View File
@@ -17,16 +17,19 @@ 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:
@@ -36,6 +39,7 @@ 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")
@@ -51,30 +55,4 @@ 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")
+1 -1
View File
@@ -1 +1 @@
"""Business logic services.""" """Business logic services."""
+7 -22
View File
@@ -40,13 +40,9 @@ 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(
container=options.get("container"), # None -> auto-detect format=options.get("format", "mp3"),
audio_codec=options.get("audio_codec", "copy"), transcode_to=options.get("transcode_to", None),
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),
@@ -70,30 +66,17 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from audio_splitter.core import split_audio from audio_splitter.core import split_audio
# Detect attached picture and extract if applicable
cover_image_path = None
if not args.drop_video:
from audio_splitter.ffmpeg import is_attached_picture, extract_cover_image, get_stream_info
input_path_str = str(input_path)
stream_info = get_stream_info(input_path_str)
if stream_info.get('has_video') and is_attached_picture(input_path_str):
cover_image_path = output_dir / 'cover.png'
if extract_cover_image(input_path_str, str(cover_image_path)):
print(f"Extracted cover image for task {task_id}")
else:
cover_image_path = None
# Attach cover image to args
args.cover_image = str(cover_image_path) if cover_image_path else None
task_manager.update_task_with_progress( task_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,
@@ -101,11 +84,13 @@ 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:
+12 -27
View File
@@ -1,42 +1,27 @@
# web/frontend/Dockerfile # Stage 1: Build
# 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
# Copy frontend package files and install dependencies # Install dependencies
COPY web/frontend/package.json web/frontend/package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install RUN npm install
# Copy the frontend source code # Copy the application code and build
COPY web/frontend/ . COPY . .
# 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;"]
+1 -4
View File
@@ -4,9 +4,6 @@
"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",
@@ -36,4 +33,4 @@
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^5.0.8" "vite": "^5.0.8"
} }
} }
+8 -7
View File
@@ -68,15 +68,16 @@ const App: React.FC = () => {
reset() reset()
} }
const isSplitDisabled = const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError
!taskId ||
!isValid ||
entries.length === 0 ||
isProcessing ||
!!formatError
return ( return (
<ThemeProvider theme={createTheme({ palette: { mode: theme } })}> <ThemeProvider
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<CssBaseline /> <CssBaseline />
<Layout> <Layout>
<Grid container spacing={3}> <Grid container spacing={3}>
+10 -39
View File
@@ -1,12 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
TracklistEntry,
SplitOptions,
TaskStatus,
FormatsResponse,
UploadResponse,
SplitResponse,
} from '../types'
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', baseURL: '/api',
@@ -15,14 +8,16 @@ export const api = axios.create({
}, },
}) })
export const uploadFile = async (file: File): Promise<UploadResponse> => { export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => {
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
} }
@@ -30,32 +25,12 @@ export const startSplit = async (
task_id: string, task_id: string,
tracklist: TracklistEntry[], tracklist: TracklistEntry[],
options: SplitOptions options: SplitOptions
): Promise<SplitResponse> => { ): Promise<{ task_id: string; status: string }> => {
// Build request payload (only send fields that are not undefined) const response = await api.post('/split', {
const payload: any = {
task_id, task_id,
tracklist, tracklist,
container: options.container, // null means auto-detect options,
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
} }
@@ -72,7 +47,8 @@ export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip` return `/api/download/${task_id}/splits.zip`
} }
export const getFormats = async (): Promise<FormatsResponse> => { // New functions for format validation feature
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
} }
@@ -87,8 +63,3 @@ 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
}
+67 -180
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from 'react' import React, { useEffect } from 'react'
import { import {
Box, Box,
Paper, Paper,
@@ -16,12 +16,7 @@ import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore' import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore' import { useValidationStore } from '../stores/validationStore'
import { getFormats } from '../api/client'
import { SplitOptions } from '../types'
// ------------------------------------------------------------------------------
// Section component (collapsible)
// ------------------------------------------------------------------------------
interface SectionProps { interface SectionProps {
title: string title: string
children: React.ReactNode children: React.ReactNode
@@ -30,6 +25,7 @@ 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
@@ -55,116 +51,54 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
) )
} }
// ------------------------------------------------------------------------------
// Main OptionsPanel
// ------------------------------------------------------------------------------
export const OptionsPanel: React.FC = () => { export const OptionsPanel: React.FC = () => {
const { const { options, setOptions } = useOptionsStore()
options,
setOptions,
containers,
codecs,
compatibility,
setContainers,
setCodecs,
setCompatibility,
} = useOptionsStore()
const { hasVideo } = useUploadStore() const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore() const { formatError, setFormatError } = useValidationStore()
// Fetch formats on mount // Audio-only formats from backend constants (hardcoded for now)
useEffect(() => { const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
const fetchFormats = async () => {
try {
const data = await getFormats()
setContainers(data.containers || [])
setCodecs(data.codecs || [])
setCompatibility(data.compatibility || {})
} catch (err) {
console.error('Failed to fetch formats:', err)
}
}
fetchFormats()
}, [setContainers, setCodecs, setCompatibility])
// -------------------------------------------------------------- const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Filter codec options based on selected container const newFormat = e.target.value
// -------------------------------------------------------------- setOptions({ format: newFormat })
const filteredAudioCodecs = useMemo(() => {
const entry = compatibility?.[options.container || '']
if (!entry) return codecs.filter(c => c.supports_transcoding)
const audioList = entry.audio
if (audioList === null) return codecs.filter(c => c.supports_transcoding)
return codecs.filter(c => c.supports_transcoding && audioList.includes(c.name))
}, [compatibility, options.container, codecs])
const filteredVideoCodecs = useMemo(() => { // Validate format
const entry = compatibility?.[options.container || ''] if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
if (!entry) return codecs.filter(c => c.supports_video)
const videoList = entry.video
if (videoList === null) return codecs.filter(c => c.supports_video)
return codecs.filter(c => c.supports_video && videoList.includes(c.name))
}, [compatibility, options.container, codecs])
// --------------------------------------------------------------
// Validate compatibility
// --------------------------------------------------------------
useEffect(() => {
const selectedContainer = containers.find(c => c.name === options.container)
if (selectedContainer?.audio_only && hasVideo && !options.drop_video) {
setFormatError( setFormatError(
`Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.` `Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
) )
return } else {
setFormatError(null)
} }
// Check audio codec compatibility
if (options.audio_codec && options.audio_codec !== 'copy' && options.container) {
const entry = compatibility?.[options.container]
if (entry) {
const audioList = entry.audio
if (audioList !== null && !audioList.includes(options.audio_codec)) {
setFormatError(
`Audio codec '${options.audio_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
// Check video codec compatibility
if (options.video_codec && options.video_codec !== 'copy' && options.container && hasVideo && !options.drop_video) {
const entry = compatibility?.[options.container]
if (entry) {
const videoList = entry.video
if (videoList !== null && !videoList.includes(options.video_codec)) {
setFormatError(
`Video codec '${options.video_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
setFormatError(null)
}, [options.container, options.audio_codec, options.video_codec, options.drop_video, hasVideo, compatibility, containers, setFormatError])
// --------------------------------------------------------------
// Handlers
// --------------------------------------------------------------
const handleChange = (field: keyof SplitOptions, value: any) => {
setOptions({ [field]: value })
} }
const handleContainerChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? null : e.target.value const checked = e.target.checked
handleChange('container', value) 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)
// Render useEffect(() => {
// -------------------------------------------------------------- const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
const newError = shouldShowError
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
: null
// Only update if the error state actually changes
if (newError !== formatError) {
setFormatError(newError)
}
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
return ( return (
<Paper sx={{ p: 3 }}> <Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
@@ -177,101 +111,55 @@ export const OptionsPanel: React.FC = () => {
</Alert> </Alert>
)} )}
{/* ------------------------------------------------------------------------ {/* Output Settings */}
Output Settings
------------------------------------------------------------------------ */}
<Section title="Output Settings" defaultExpanded> <Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Container dropdown */}
<TextField <TextField
label="Container" label="Format"
select select
value={options.container ?? ''} value={options.format}
onChange={handleContainerChange} onChange={handleFormatChange}
fullWidth fullWidth
size="small" size="small"
error={!!formatError} error={!!formatError}
helperText={
formatError || "Select a container (auto-detect if empty)."
}
> >
<MenuItem value="">Auto-detect</MenuItem> <MenuItem value="mp3">MP3</MenuItem>
{containers.map((c) => ( <MenuItem value="m4a">M4A</MenuItem>
<MenuItem key={c.name} value={c.name}> <MenuItem value="mkv">MKV</MenuItem>
{c.name.toUpperCase()} ({c.extension}) <MenuItem value="mp4">MP4</MenuItem>
</MenuItem> <MenuItem value="ogg">OGG</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="Audio Codec" label="Transcode to"
select select
value={options.audio_codec} value={options.transcode_to || ''}
onChange={(e) => handleChange('audio_codec', e.target.value)} onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
fullWidth fullWidth
size="small" size="small"
helperText="Audio codec (copy = keep original)"
> >
<MenuItem value="copy">Copy (original)</MenuItem> <MenuItem value="">Copy (no transcoding)</MenuItem>
{filteredAudioCodecs.map((c) => ( <MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
<MenuItem key={c.name} value={c.name}> <MenuItem value="aac">AAC</MenuItem>
{c.name.toUpperCase()} <MenuItem value="libopus">OPUS</MenuItem>
</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={(e) => handleChange('drop_video', e.target.checked)} onChange={handleDropVideoChange}
/> />
} }
label="Drop video streams" label="Drop video streams"
/> />
)} )}
{/* Drop subtitles */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -284,9 +172,7 @@ 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
@@ -342,9 +228,7 @@ 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
@@ -375,7 +259,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, 10)) handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
} }
size="small" size="small"
disabled={options.no_comment} disabled={options.no_comment}
@@ -400,9 +284,7 @@ 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
@@ -417,4 +299,9 @@ export const OptionsPanel: React.FC = () => {
</Section> </Section>
</Paper> </Paper>
) )
// Helper function for option updates
function handleChange(field: string, value: any) {
setOptions({ [field]: value })
}
} }
+12 -41
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, 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,9 +10,6 @@ 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)
@@ -26,29 +23,10 @@ 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) => {
@@ -70,6 +48,13 @@ 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 (
@@ -97,13 +82,12 @@ export const TracklistEditor: React.FC = () => {
</Typography> </Typography>
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 2, position: 'relative' }}> <Box sx={{ display: 'flex', gap: 2 }}>
{/* Line numbers column */} {/* Line numbers column */}
<Box <Box
ref={lineNumbersRef}
sx={{ sx={{
minWidth: 40, minWidth: 40,
maxWidth: 60, // Allow more space for 3-digit numbers maxWidth: 40,
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '14px', fontSize: '14px',
lineHeight: 1.7, lineHeight: 1.7,
@@ -111,11 +95,6 @@ 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) => (
@@ -123,7 +102,7 @@ export const TracklistEditor: React.FC = () => {
))} ))}
</Box> </Box>
{/* Editor text area – now with horizontal scroll and no wrap */} {/* Editor text area */}
<TextField <TextField
multiline multiline
fullWidth fullWidth
@@ -133,19 +112,11 @@ 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}
+3 -15
View File
@@ -3,8 +3,7 @@ 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 { useOptionsStore } from '../stores/optionsStore' // NEW import { uploadFile, getTaskInfo } from '../api/client'
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']
@@ -31,7 +30,6 @@ 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[]) => {
@@ -67,19 +65,9 @@ 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 upload flow; user can manually change options // Don't block the upload flow if this fails; we'll just assume no video
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Upload failed') setError(err.response?.data?.detail || err.message || 'Upload failed')
@@ -90,7 +78,7 @@ export const UploadZone: React.FC = () => {
setFileSize(0) setFileSize(0)
} }
}, },
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore, setHasVideo, setHasAudio, setHasSubtitle, setAudioCodec, setOptions] [setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
) )
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+20 -60
View File
@@ -1,77 +1,37 @@
import { create } from 'zustand' import { create } from 'zustand'
import { SplitOptions, ContainerInfo, CodecInfo } from '../types' import { SplitOptions } 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 = {
container: null, format: 'mp3',
audio_codec: 'copy', transcode_to: '',
video_codec: 'copy', drop_video: false,
subtitle_codec: 'copy', drop_subs: false,
video_quality: null, number_tracks: false,
drop_video: DEFAULT_DROP_VIDEO, replace_bad_chars: false,
drop_subs: DEFAULT_DROP_SUBS, replacement_char: '_',
number_tracks: DEFAULT_NUMBER_TRACKS, bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ',
replace_bad_chars: DEFAULT_REPLACE_BAD_CHARS, skip_existing: false,
replacement_char: DEFAULT_REPLACEMENT_CHAR, output_template: '%an-%tn.%ext',
bad_chars: DEFAULT_BAD_CHARS, album: '',
skip_existing: DEFAULT_SKIP_EXISTING, comment: '',
output_template: DEFAULT_OUTPUT_TEMPLATE, no_comment: false,
album: DEFAULT_ALBUM ?? '', comment_stream: null,
comment: DEFAULT_COMMENT ?? '', merge_comments: false,
no_comment: DEFAULT_NO_COMMENT, comment_separator: '; ',
comment_stream: DEFAULT_COMMENT_STREAM, tracklist_format: '%ts %tn - %an', // NEW
merge_comments: DEFAULT_MERGE_COMMENTS,
comment_separator: DEFAULT_COMMENT_SEPARATOR,
tracklist_format: DEFAULT_TRACKLIST_FORMAT,
} }
interface OptionsState { interface OptionsState {
options: SplitOptions options: SplitOptions
containers: ContainerInfo[] setOptions: (options: Partial<SplitOptions>) => void
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 },
})), })),
setContainers: (containers) => set({ containers }), reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
setCodecs: (codecs) => set({ codecs }),
setCompatibility: (compatibility) => set({ compatibility }),
reset: () =>
set({
options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
}),
})) }))
+3 -35
View File
@@ -22,11 +22,8 @@ export interface TaskStatus {
} }
export interface SplitOptions { export interface SplitOptions {
container: string | null // null = auto-detect format: string
audio_codec: string // 'copy' or encoder name transcode_to?: string
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
@@ -41,36 +38,7 @@ 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 // for frontend parsing tracklist_format: string
}
export interface ContainerInfo {
name: string
ffmpeg: string
extension: string
audio_only: boolean
supports_video: boolean
supports_subs: boolean
}
export interface CodecInfo {
name: string
ffmpeg: string
recommended_container: string
recommended_extension: string
supports_transcoding: boolean
supports_video: boolean
}
export interface CompatibilityEntry {
audio: string[] | null
video: string[] | null
}
export interface FormatsResponse {
containers: ContainerInfo[]
codecs: CodecInfo[]
compatibility: Record<string, CompatibilityEntry>
} }
export interface UploadResponse { export interface UploadResponse {