22 Commits

Author SHA1 Message Date
max 8e0d345ba7 FIX: maps codecs names and ffmpeg encoders correctly 2026-08-28 15:41:21 +05:00
max 3533525df0 FEATURE: propagate improvements from CLI and web-API versions to web-frontend 2026-08-28 15:40:38 +05:00
max 834239674a FEATURE: propogate improvements from CLI version to web-backend 2026-08-28 13:24:46 +05:00
max 748532be7c FIX: codec/container/file_extension autodetection when default values are used 2026-08-28 11:04:40 +05:00
max f3abbb8f0c FEATURE: adds quality flag for video stream 2026-08-25 17:00:43 +05:00
max 690c5f98c6 FEATURE: adds strict codec/container/file_extension verification, renames ambiguous options, adds flexible transcoding 2026-08-25 10:40:48 +05:00
max 262d458ce4 FEATURE: codecs/containers/file_extensions inconsistency fixed 2026-08-21 15:59:05 +05:00
max 7af2600ed8 FEATURE: all versions use single source of truth for codec/container/file_extension info 2026-08-21 14:58:55 +05:00
max d08045bf02 Merge pull request 'fix(frontend): frontend functional bugs' (#9) from frontend_bugs into dev
Reviewed-on: #9
2026-08-21 13:35:17 +04:00
max 4843ac1cb5 FEATURE: clarifes 'format' option in output section of the frontend 2026-08-20 20:21:15 +05:00
max 364fc7a8fe FEATURE: adds automatic format detected to the frontend 2026-08-20 19:59:11 +05:00
max 8a1e2a5335 FIX: aligns line number with the corresponding lines in the tracklist editor 2026-08-20 12:47:58 +05:00
max 3c7ff97e76 Merge pull request 'feat(core): single source of truth for default values added' (#6) from default_args into dev
Reviewed-on: #6
2026-08-20 09:34:21 +04:00
max 140a0fbc46 FEATURE: single source of truth for default values added 2026-08-20 10:28:07 +05:00
max 05fcc8ea29 Merge pull request 'FEAT(backend): allows the backend to take a tracklis text file' (#5) from backend_tracks_file into dev
Reviewed-on: #5
2026-08-19 16:30:10 +04:00
max 52d01f1bcd FEATURE: allows the backend to take a tracklis text file 2026-08-19 17:26:07 +05:00
max 607836b280 CHANGE: drops cli version docker support 2026-08-19 13:12:34 +05:00
max 5e124645c0 Merge pull request 'feat(core): output format detected logic improved' (#4) from reverse-proxy into main
Reviewed-on: #4
2026-08-19 10:21:11 +04:00
max 02b730b082 CHANGE: maps codec/container/file_extension more accurate
Build and Push Docker Image / build-and-push-backend (pull_request) Failing after 19m21s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 54s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 11s
2026-08-19 11:12:48 +05:00
max c27f832c95 Update README.md 2026-08-07 06:35:53 +00:00
max 5cf6812338 Update README.md 2026-08-07 06:34:36 +00:00
max bb883409e1 run-name added for images building action 2026-08-05 17:11:43 +05:00
31 changed files with 1597 additions and 862 deletions
+1
View File
@@ -1,4 +1,5 @@
name: Build and Push Docker Image name: Build and Push Docker Image
run-name: Production images are being built
on: on:
pull_request: pull_request:
-57
View File
@@ -1,57 +0,0 @@
FROM python:3.13-slim
# Install FFmpeg and dependencies required for gosu installation
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
wget \
gnupg \
dirmngr \
gnupg-agent && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install gosu (lightweight tool for dropping privileges)
RUN set -eux; \
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch"; \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch.asc"; \
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
chmod +x /usr/local/bin/gosu; \
gosu --version
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set working directory
WORKDIR /app
# Copy package metadata and source code
COPY setup.py pyproject.toml README.md ./
COPY audio_splitter/ ./audio_splitter/
# Install the package
RUN pip install --no-cache-dir .
# Create a non-root user with UID 1000
RUN addgroup --system --gid 1000 appgroup && \
adduser --system --uid 1000 --ingroup appgroup appuser
# Change ownership of /app to the container user (so it can write there if needed)
RUN chown -R appuser:appgroup /app
# Copy the entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set the entrypoint
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# Default command (shows help if no arguments provided)
CMD ["--help"]
+4 -303
View File
@@ -11,6 +11,10 @@
--- ---
## 📖 Documentation
Full documentation including all CLI options, tracklist formats, deployment guides, and architecture details is available in the [**Wiki**](https://git.vmn.su/max/audio_splitter/wiki).
## ✨ Features ## ✨ Features
- **Flexible tracklist parsing** Define your own format with placeholders (`%ts`, `%tn`, `%an`, `%al`, `%date`, `%ext`) - **Flexible tracklist parsing** Define your own format with placeholders (`%ts`, `%tn`, `%an`, `%al`, `%date`, `%ext`)
@@ -26,310 +30,7 @@
--- ---
## 📦 Installation
### Prerequisites
- **Python 3.8 or higher**
- **FFmpeg** required for audio processing
### Install FFmpeg
| OS | Command |
| -------------------- | ------------------------------------------------------------ |
| **Ubuntu/Debian** | `sudo apt install ffmpeg` |
| **macOS (Homebrew)** | `brew install ffmpeg` |
| **Windows** | Download from [ffmpeg.org](https://ffmpeg.org/download.html) |
### Install Audio Splitter
Clone the repository and install:
```bash
git clone https://git.vmn.su/max/audio_splitter.git
cd audio_splitter
pip install .
```
Now the `audio_splitter` command is available globally:
```bash
audio_splitter input.mp3 tracks.txt
```
> **Tip:** For development, install in editable mode: `pip install -e .`
---
## 🚀 Quick Start
### 1. Prepare a tracklist file
Create a `tracks.txt` file with one track per line:
```
00:00 Intro
01:30 Song One - Artist A
04:20-06:45 Another Song - Artist B
08:10 Finale - Artist C
```
- `00:00` startonly timestamp (track ends at next track's start or end of file)
- `04:20-06:45` explicit start and end timestamps
### 2. Run the splitter
```bash
audio_splitter my_album.mp3 tracks.txt
```
Output:
```
Found 4 tracks.
Detected streams: audio=True, video=False, subs=False
Audio codec: mp3
Output container: mp3
Extracting track 1: Intro (00:00:00 - 00:01:30)
-> Saved to: my_album_splits/Intro.mp3
Extracting track 2: Song One (00:01:30 - 00:04:20)
-> Saved to: my_album_splits/Song One - Artist A.mp3
...
Done!
```
---
## ⚙️ CommandLine Options
### Basic Options
| Option | Description |
| ---------------------- | ------------------------------------------------------------- |
| `input_file` | Input audio/video file |
| `tracklist_file` | Tracklist file |
| `-o, --output-dir DIR` | Output directory (default: `<input>_splits`) |
| `--format FORMAT` | Output container format (mp3, m4a, mkv, mp4, ogg, opus, etc.) |
| `--transcode-to CODEC` | Reencode audio to CODEC (e.g., libmp3lame, aac, libopus) |
| `--drop-video` | Remove video streams |
| `--drop-subs` | Remove subtitle streams |
| `--dry-run` | Preview parsed tracklist without splitting |
### Filename Options
| Option | Description |
| ---------------------------- | --------------------------------------------------------------- |
| `--number-tracks` | Prepend track number (`01 - `) to filenames |
| `--output-template TEMPLATE` | Custom filename template (default: `%an-%tn.%ext`) |
| `--replace-bad-chars` | Replace problematic characters in filenames |
| `--replacement-char CHAR` | Replacement character (default: `_`) |
| `--bad-chars CHARS` | Characters to replace (default includes space and single quote) |
### Metadata Options
| Option | Description |
| ------------------------- | ----------------------------------------------- |
| `--album ALBUM` | Set album name (overrides parsed `%al`) |
| `--comment COMMENT` | Set comment text |
| `--no-comment` | Ignore comment entirely |
| `--comment-stream INDEX` | Select comment from a specific stream (0based) |
| `--merge-comments` | Merge all comments from all streams |
| `--comment-separator SEP` | Separator for merged comments (default: `; `) |
### Tracklist Format Options
| Option | Description |
| --------------------------- | -------------------------------------------------- |
| `--tracklist-format FORMAT` | Custom tracklist format (default: `%ts %tn - %an`) |
| `--skip-existing` | Skip extraction if output file already exists |
### Other Options
| Option | Description |
| ------------------- | --------------------------------------------------------- |
| `--delete-original` | Delete the original input file after successful splitting |
---
## 📝 Placeholders Reference
### Tracklist Format Placeholders (`--tracklist-format`)
| Placeholder | Meaning |
| ----------- | --------------------------------------------------- |
| `%ts` | **Timestamp** required (`00:00` or `00:00-01:30`) |
| `%tn` | Track name |
| `%an` | Author/artist |
| `%al` | Album |
| `%date` | Date/year |
| `%ext` | File extension |
**Default:** `%ts %tn - %an`
### Output Template Placeholders (`--output-template`)
| Placeholder | Meaning |
| ----------- | -------------------------------------- |
| `%tn` | Track name |
| `%an` | Author/artist |
| `%al` | Album |
| `%date` | Date/year |
| `%ext` | File extension (without leading dot) |
| `%num` | Track number (zeropadded, e.g., `01`) |
**Default:** `%an-%tn.%ext`
---
## 💡 Examples
### Custom tracklist format
If your tracklist uses `artist - title [time]`:
```bash
audio_splitter input.flac tracks.txt \
--tracklist-format "%an - %tn [%ts]"
```
### Custom output filenames
Name files as `01 - Artist - Song.mp3`:
```bash
audio_splitter input.flac tracks.txt \
--output-template "%num - %an - %tn.%ext"
```
### Override album and comment
```bash
audio_splitter input.flac tracks.txt \
--album "Greatest Hits" \
--comment "Live recording"
```
### Merge multiple comments from input file
```bash
audio_splitter input.flac tracks.txt \
--merge-comments \
--comment-separator " | "
```
### Delete original file after splitting
```bash
audio_splitter input.flac tracks.txt --delete-original
```
### Dryrun to preview parsing
```bash
audio_splitter input.flac tracks.txt --dry-run
```
Output:
```
Parsed tracklist:
------------------------------------------------------------
ts | tn | an
------------------------------------------------------------
1 | 00:00 | Intro |
2 | 01:30 | Song One | Artist A
3 | 04:20-06:45 | Another Song | Artist B
...
------------------------------------------------------------
Dryrun complete. No files were created.
```
---
## 🐳 Docker
You can run `audio_splitter` in a Docker container without installing Python or FFmpeg on your host.
### Pull the Image (Optional)
```bash
docker pull yourusername/audio_splitter:latest
```
### Build the Image Locally
```bash
docker build -t audio_splitter .
```
### Usage
**You must mount your working directory to `/data` inside the container.**
The container will automatically adjust permissions so that you can read input files and write output files.
Simply provide the arguments as you would to the `audio_splitter` command:
```bash
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt [OPTIONS]
```
#### Examples
**Basic split:**
```bash
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt
```
**With custom options:**
```bash
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt --album "Greatest Hits" --format mp3 --number-tracks
```
**Dryrun:**
```bash
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt --dry-run
```
**Help:**
```bash
docker run --rm audio_splitter --help
```
### Output
All output files are written to the mounted directory on your host (under the default `input_splits/` subdirectory, or any custom `--output-dir` you specify).
### Permission Handling
The container automatically adjusts ownership of the mounted `/data` directory so that the container user can read and write files there. No `--user` or `:z` flags are required.
---
## 📂 Project Structure
```
audio_splitter/
├── __init__.py # Package initialisation
├── constants.py # Global constants (FORMAT_INFO, DEFAULT_BAD_CHARS)
├── utils.py # Generic helpers (timestamps, string manipulation)
├── tracklist.py # Tracklist parsing with custom formats
├── ffmpeg.py # FFmpeg/FFprobe interactions and command building
├── timestamp.py # Timestamp parsing and resolution
├── filename.py # Output filename generation
├── metadata.py # Metadata selection and building
├── formats.py # Container format decision and validation
├── core.py # Main orchestration logic
├── main.py # Commandline interface
├── docker-entrypoint.sh # Docker entrypoint script
├── Dockerfile # Docker image definition
└── README.md # This file
```
---
## 🤝 Contributing ## 🤝 Contributing
+131 -16
View File
@@ -1,20 +1,135 @@
"""Global constants and default values.""" # audio_splitter/constants.py
"""Global constants for the audio splitter.
# Mapping from userfriendly format names to FFmpeg format identifiers, This file serves as the single source of truth for:
# file extensions, and whether the container is audioonly. - Container formats and their properties
- Audio codecs and their recommended containers
- Video codec support per container
- File extensions for each codec/container combination
- Compatibility matrix for codec/container validation
Codec != Container != File Extension.
Example: Opus (codec) → Ogg (container) → .opus (extension)
"""
# ------------------------------------------------------------------------------
# Container information
# ------------------------------------------------------------------------------
CONTAINER_INFO = [
# Audio-only containers
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True, 'supports_video': False, 'supports_subs': False},
# Containers that support video and subtitles
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'matroska', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': False, 'supports_video': True, 'supports_subs': True},
{'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'audio_only': False, 'supports_video': True, 'supports_subs': False},
]
# Legacy FORMAT_INFO for backward compatibility
FORMAT_INFO = { FORMAT_INFO = {
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True}, container['name']: {
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True}, 'ffmpeg': container['ffmpeg'],
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False}, 'ext': container['extension'],
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False}, 'audio_only': container['audio_only'],
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False}, }
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True}, for container in CONTAINER_INFO
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
} }
# Default characters to replace when --replace-bad-chars is enabled. # ------------------------------------------------------------------------------
# Includes common punctuation, single quote, and a trailing space. # Codec information
DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' ' # ------------------------------------------------------------------------------
CODEC_INFO = [
# Audio codecs
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'supports_transcoding': True, 'supports_video': False},
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'supports_transcoding': True, 'supports_video': False},
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'supports_transcoding': True, 'supports_video': False},
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'supports_transcoding': True, 'supports_video': False},
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False},
{'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'supports_transcoding': True, 'supports_video': False},
# Video codecs
{'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'supports_transcoding': True, 'supports_video': True},
{'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True},
{'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True},
{'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'supports_transcoding': True, 'supports_video': True},
{'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True},
{'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True},
{'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'supports_transcoding': False, 'supports_video': True},
{'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'supports_transcoding': False, 'supports_video': True},
]
# Map codec name → recommended container
CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO}
CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO}
# Map codec name → FFmpeg encoder name
CODEC_NAME_TO_FFMPEG = {codec['name']: codec['ffmpeg'] for codec in CODEC_INFO}
# ------------------------------------------------------------------------------
# Video codec support per container (legacy, will be superseded by compatibility matrix)
# ------------------------------------------------------------------------------
CONTAINER_VIDEO_CODEC_SUPPORT = {
'mp4': ['h264', 'h265', 'vp9', 'av1', 'mpeg4', 'hevc'],
'mkv': ['*'],
'matroska': ['*'],
'ogg': ['theora', 'dirac', 'vp8', 'png', 'mjpeg'],
'webm': ['vp8', 'vp9', 'av1'],
'mp3': [],
'm4a': [],
'flac': [],
'wav': [],
'aac': [],
}
# ------------------------------------------------------------------------------
# Compatibility matrix: container → supported audio and video codecs
# ------------------------------------------------------------------------------
# Each container entry maps to a dict with 'audio' and 'video' keys.
# - 'audio': list of audio codec names that are supported in this container.
# Use None to indicate that any audio codec is supported.
# - 'video': list of video codec names that are supported in this container.
# Use None to indicate that any video codec is supported.
# Empty list means no video support (audio-only container).
COMPATIBILITY_MATRIX = {
'mp3': {'audio': ['mp3'], 'video': []},
'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []},
'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']},
'mkv': {'audio': None, 'video': None},
'matroska': {'audio': None, 'video': None},
'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8']},
'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']},
'flac': {'audio': ['flac'], 'video': []},
'wav': {'audio': ['pcm_s16le'], 'video': []},
'aac': {'audio': ['aac'], 'video': []},
}
# ------------------------------------------------------------------------------
# Helper functions for compatibility checking
# ------------------------------------------------------------------------------
def is_audio_codec_supported(container: str, audio_codec: str) -> bool:
"""Check if an audio codec is supported in the given container."""
entry = COMPATIBILITY_MATRIX.get(container, {})
supported = entry.get('audio')
if supported is None:
return True
return audio_codec in supported
def is_video_codec_supported(container: str, video_codec: str) -> bool:
"""Check if a video codec is supported in the given container."""
entry = COMPATIBILITY_MATRIX.get(container, {})
supported = entry.get('video')
if supported is None:
return True
return video_codec in supported
# ------------------------------------------------------------------------------
# Default bad characters (for filename sanitization)
# ------------------------------------------------------------------------------
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
+146 -29
View File
@@ -1,30 +1,68 @@
"""Core logic: orchestrates the splitting process.""" # audio_splitter/core.py
"""Core splitting logic orchestrates the entire split process."""
import os import os
import subprocess import subprocess
import sys
from typing import List, Dict, Any
from .constants import FORMAT_INFO from .constants import (
from .ffmpeg import get_audio_duration, get_stream_info, get_metadata, build_ffmpeg_command FORMAT_INFO,
from .formats import determine_output_format, validate_format_compatibility CODEC_TO_EXTENSION_MAP,
CODEC_NAME_TO_FFMPEG, # <-- Add this
)
from .ffmpeg import (
get_audio_duration,
get_stream_info,
get_metadata,
build_ffmpeg_command,
is_attached_picture,
extract_cover_image,
)
from .formats import (
determine_output_format,
validate_format_compatibility,
)
from .timestamp import parse_track_timestamps, resolve_end_times from .timestamp import parse_track_timestamps, resolve_end_times
from .filename import build_filename from .filename import build_filename
from .metadata import build_metadata_dict from .metadata import build_metadata_dict
from .utils import format_time from .utils import format_time
def split_audio(input_file, output_directory, tracks, args): def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, Any]], args) -> None:
""" """
Main orchestration function: split the audio file into tracks. Main orchestration function: split the audio file into tracks.
Args: Args:
input_file: Path to the input media file. input_file: Path to the input media file.
output_directory: Directory where output files will be saved. output_directory: Directory where output files will be saved.
tracks: List of dicts, each containing parsed fields. tracks: List of dicts, each containing parsed fields (ts, tn, an, ...).
args: Parsed commandline arguments (namespace). args: Parsed commandline arguments (namespace) with attributes:
- container: output container name (or None for auto-detect)
- audio_codec: audio codec (copy or encoder)
- video_codec: video codec (copy or encoder)
- subtitle_codec: subtitle codec (copy or encoder)
- video_quality: integer or None
- drop_video: bool
- drop_subs: bool
- number_tracks: bool
- output_template: str
- replace_bad_chars: bool
- replacement_char: str
- bad_chars: str
- skip_existing: bool
- album: str or None
- comment: str or None
- no_comment: bool
- comment_stream: int or None
- merge_comments: bool
- comment_separator: str
- delete_original: bool
- cover_image: str or None (optional, set by web backend)
Raises: Raises:
RuntimeError: If no audio stream is found. RuntimeError: If no audio stream is found.
ValueError: If timestamp parsing or format compatibility fails. ValueError: If compatibility validation fails.
""" """
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 1. Setup # 1. Setup
@@ -42,26 +80,58 @@ def split_audio(input_file, output_directory, tracks, args):
raise RuntimeError("No audio stream found in input file.") raise RuntimeError("No audio stream found in input file.")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 2. Format decision # 2. Determine output container
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
output_format = determine_output_format(stream_info, args.format, args.transcode_to) user_container = getattr(args, 'container', None)
print(f"Output container: {output_format}") output_container = determine_output_format(
stream_info,
validate_format_compatibility(output_format, stream_info, user_format=user_container,
args.drop_video, args.drop_subs) transcode_audio=args.audio_codec if args.audio_codec != 'copy' else None,
input_file=input_file
# Determine file extension. )
extension_info = FORMAT_INFO.get(output_format, {}) print(f"Output container: {output_container}")
extension = extension_info.get('ext', '.mkv')
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 3. Parse timestamps # 3. Validate compatibility
# --------------------------------------------------------------------------
try:
validate_format_compatibility(
container=output_container,
stream_info=stream_info,
drop_video=args.drop_video,
drop_subs=args.drop_subs,
input_file=input_file,
audio_codec=args.audio_codec,
video_codec=args.video_codec,
)
except ValueError as e:
raise RuntimeError(f"Compatibility error: {e}")
# --------------------------------------------------------------------------
# 4. Determine output audio codec and extension
# --------------------------------------------------------------------------
# Determine the audio codec that will be used in the output
if args.audio_codec != 'copy':
output_audio_codec = args.audio_codec
else:
output_audio_codec = stream_info.get('audio_codec', '')
# Choose extension based on audio codec if possible, otherwise fallback to container default
if output_audio_codec in CODEC_TO_EXTENSION_MAP:
extension = CODEC_TO_EXTENSION_MAP[output_audio_codec]
else:
extension = FORMAT_INFO.get(output_container, {}).get('ext', '.mkv')
print(f"Output extension: {extension}")
# --------------------------------------------------------------------------
# 5. Parse timestamps
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
track_times = parse_track_timestamps(tracks) track_times = parse_track_timestamps(tracks)
resolved_times = resolve_end_times(track_times, total_duration) resolved_times = resolve_end_times(track_times, total_duration)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 4. Fetch original metadata (for fallbacks) # 6. Fetch original metadata (for fallbacks)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
input_metadata = get_metadata(input_file) input_metadata = get_metadata(input_file)
original_album = input_metadata.get('album') original_album = input_metadata.get('album')
@@ -78,7 +148,22 @@ def split_audio(input_file, output_directory, tracks, args):
print(f" Stream {idx}: '{comment}'") print(f" Stream {idx}: '{comment}'")
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 5. Process each track # 7. Handle attached picture (cover art)
# --------------------------------------------------------------------------
cover_image_path = getattr(args, 'cover_image', None)
if cover_image_path and not os.path.exists(cover_image_path):
cover_image_path = None
if not cover_image_path and not args.drop_video and stream_info.get('has_video'):
if is_attached_picture(input_file):
cover_image_path = os.path.join(output_directory, 'cover.png')
if extract_cover_image(input_file, cover_image_path):
print("Extracted cover image for all tracks.")
else:
cover_image_path = None
# --------------------------------------------------------------------------
# 8. Process each track
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
for idx, track in enumerate(tracks, start=1): for idx, track in enumerate(tracks, start=1):
start_seconds, end_seconds = resolved_times[idx - 1] start_seconds, end_seconds = resolved_times[idx - 1]
@@ -91,31 +176,53 @@ def split_audio(input_file, output_directory, tracks, args):
track_name = track.get('tn', 'Unknown') track_name = track.get('tn', 'Unknown')
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5a. Build filename # 8a. Build filename
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
clean_filename = build_filename(track, idx, extension, args) clean_filename = build_filename(track, idx, extension, args)
output_path = os.path.join(output_directory, clean_filename) output_path = os.path.join(output_directory, clean_filename)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5b. Handle existing files # 8b. Handle existing files
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
if args.skip_existing and os.path.exists(output_path): if args.skip_existing and os.path.exists(output_path):
print(f"Skipping track {idx}: {output_path} already exists.") print(f"Skipping track {idx}: {output_path} already exists.")
continue continue
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5c. Build metadata # 8c. Build metadata
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
metadata = build_metadata_dict(track, idx, input_metadata, args) metadata = build_metadata_dict(track, idx, input_metadata, args)
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 5d. Build and execute FFmpeg command # 8d. Build and execute FFmpeg command
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Map codec names to FFmpeg encoder names
audio_enc = args.audio_codec
if audio_enc != 'copy':
audio_enc = CODEC_NAME_TO_FFMPEG.get(audio_enc, audio_enc) # fallback to itself if not found
video_enc = args.video_codec
if video_enc != 'copy':
video_enc = CODEC_NAME_TO_FFMPEG.get(video_enc, video_enc)
subtitle_enc = args.subtitle_codec
if subtitle_enc != 'copy':
subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc)
# Then build command with these mapped encoders
cmd = build_ffmpeg_command( cmd = build_ffmpeg_command(
input_file, start_seconds, duration_seconds, output_path, input_file=input_file,
stream_info, output_format, args.transcode_to, start_seconds=start_seconds,
args.drop_video, args.drop_subs, duration_seconds=duration_seconds,
metadata=metadata output_path=output_path,
stream_info=stream_info,
format_opt=output_container,
audio_codec=audio_enc,
video_codec=video_enc,
subtitle_codec=subtitle_enc,
metadata=metadata,
cover_image_path=cover_image_path,
video_quality=getattr(args, 'video_quality', None),
drop_video=args.drop_video,
drop_subs=args.drop_subs,
) )
print(f"Extracting track {idx}: {track_name} " print(f"Extracting track {idx}: {track_name} "
@@ -128,3 +235,13 @@ def split_audio(input_file, output_directory, tracks, args):
print(result.stderr) print(result.stderr)
else: else:
print(f" -> Saved to: {output_path}") print(f" -> Saved to: {output_path}")
# --------------------------------------------------------------------------
# 9. Delete original file if requested
# --------------------------------------------------------------------------
if getattr(args, 'delete_original', False):
try:
os.remove(input_file)
print(f"Deleted original file: {input_file}")
except OSError as e:
print(f"Warning: Could not delete original file: {e}")
+35
View File
@@ -0,0 +1,35 @@
"""Default values for all configurable options shared between CLI, web backend, and frontend."""
# Output container format (used when user doesn't specify one; auto-detection overrides this)
DEFAULT_FORMAT = "mp3"
# Filename template
DEFAULT_OUTPUT_TEMPLATE = "%an-%tn.%ext"
# Character replacement
DEFAULT_REPLACEMENT_CHAR = "_"
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
# Tracklist parsing
DEFAULT_TRACKLIST_FORMAT = "%ts %tn - %an"
# Metadata defaults
DEFAULT_ALBUM = None
DEFAULT_COMMENT = None
DEFAULT_COMMENT_STREAM = None
DEFAULT_COMMENT_SEPARATOR = "; "
DEFAULT_NO_COMMENT = False
DEFAULT_MERGE_COMMENTS = False
# Stream handling
DEFAULT_DROP_VIDEO = False
DEFAULT_DROP_SUBS = False
# Filename/export options
DEFAULT_NUMBER_TRACKS = False
DEFAULT_REPLACE_BAD_CHARS = True
DEFAULT_SKIP_EXISTING = False
DEFAULT_DELETE_ORIGINAL = False
# Transcoding (None means copy codec)
DEFAULT_TRANSCODE_TO = None
+279 -101
View File
@@ -1,7 +1,9 @@
"""FFmpeg / FFprobe interactions and command building.""" # audio_splitter/ffmpeg.py
"""FFmpeg/FFprobe interaction utilities."""
import subprocess
import json import json
import subprocess
from typing import Dict, List, Optional, Tuple
from .constants import FORMAT_INFO from .constants import FORMAT_INFO
from .utils import format_time from .utils import format_time
@@ -24,7 +26,10 @@ def get_audio_duration(input_file: str) -> float:
input_file input_file
] ]
result = subprocess.run(cmd, capture_output=True, text=True, check=False) result = subprocess.run(cmd, capture_output=True, text=True, check=False)
try:
return float(result.stdout.strip()) return float(result.stdout.strip())
except ValueError:
return 0.0
def has_stream_type(input_file: str, stream_type: str) -> bool: def has_stream_type(input_file: str, stream_type: str) -> bool:
@@ -49,7 +54,7 @@ def has_stream_type(input_file: str, stream_type: str) -> bool:
return bool(result.stdout.strip()) return bool(result.stdout.strip())
def get_audio_codec(input_file: str) -> str: def get_audio_codec(input_file: str) -> Optional[str]:
""" """
Return the codec name of the first audio stream. Return the codec name of the first audio stream.
@@ -71,7 +76,29 @@ def get_audio_codec(input_file: str) -> str:
return codec if codec else None return codec if codec else None
def get_stream_info(input_file: str): def get_video_codec(input_file: str) -> Optional[str]:
"""
Return the codec name of the first video stream.
Args:
input_file: Path to the media file.
Returns:
Codec name as a lowercase string, or None if no video stream exists.
"""
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'v:0',
'-show_entries', 'stream=codec_name',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
codec = result.stdout.strip().lower()
return codec if codec else None
def get_stream_info(input_file: str) -> Dict[str, any]:
""" """
Collect information about the streams present in the input file. Collect information about the streams present in the input file.
@@ -88,94 +115,12 @@ def get_stream_info(input_file: str):
'audio_codec': get_audio_codec(input_file) 'audio_codec': get_audio_codec(input_file)
} }
def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_path,
stream_info, format_opt, transcode_audio,
drop_video, drop_subs, metadata=None):
"""
Construct the FFmpeg command line as a list of arguments.
Args: def get_metadata(input_file: str) -> Dict[str, any]:
input_file: Path to the input media file.
start_seconds: Start time for the segment (in seconds).
duration_seconds: Duration of the segment (in seconds).
output_path: Destination path for the output file.
stream_info: Dictionary from get_stream_info().
format_opt: Output container format (e.g., 'mp3').
transcode_audio: Audio codec to transcode to (or None).
drop_video: True to remove video streams.
drop_subs: True to remove subtitle streams.
metadata: Optional dict of metadata key/value pairs to write.
Returns:
A list of commandline arguments suitable for subprocess.run().
"""
cmd = [
'ffmpeg',
'-i', input_file,
'-ss', format_time(start_seconds),
'-t', format_time(duration_seconds)
]
# -------------------- Clear all original metadata --------------------
cmd.append('-map_metadata')
cmd.append('-1')
# -------------------- Apply custom metadata --------------------
if metadata:
for key, value in metadata.items():
if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"])
# -------------------- Stream mapping --------------------
# Map the streams we want to keep.
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 transcode_audio:
cmd.extend(['-c:a', transcode_audio])
if transcode_audio in ('libmp3lame', 'mp3'):
cmd.extend(['-b:a', '192k'])
elif transcode_audio in ('libopus', 'opus'):
cmd.extend(['-b:a', '128k'])
else:
cmd.extend(['-c:a', 'copy'])
# -------------------- Video codec --------------------
if not drop_video and stream_info['has_video']:
cmd.extend(['-c:v', 'copy'])
else:
cmd.append('-vn')
# -------------------- Subtitle codec --------------------
if not drop_subs and stream_info['has_subtitle']:
cmd.extend(['-c:s', 'copy'])
else:
cmd.append('-sn')
# -------------------- Output format --------------------
if format_opt:
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
cmd.extend(['-f', ffmpeg_format])
# Overwrite output if it already exists.
cmd.extend(['-y', output_path])
return cmd
def get_metadata(input_file: str) -> dict:
""" """
Retrieve metadata from the input file using ffprobe with JSON output. Retrieve metadata from the input file using ffprobe with JSON output.
Returns a dict with:
- album: merged from all sources (last wins) Returns a dict with keys: album, title, comments (list of (stream_index, comment)).
- title: merged from all sources (last wins)
- comments: list of (stream_index, comment) tuples
""" """
cmd = [ cmd = [
'ffprobe', '-v', 'quiet', 'ffprobe', '-v', 'quiet',
@@ -190,34 +135,267 @@ def get_metadata(input_file: str) -> dict:
try: try:
data = json.loads(result.stdout) data = json.loads(result.stdout)
# Collect album and title (merged, last wins).
album = None album = None
title = None title = None
comments = [] # list of (stream_index, comment) comments = []
# Format tags.
fmt_tags = data.get('format', {}).get('tags', {}) fmt_tags = data.get('format', {}).get('tags', {})
album = fmt_tags.get('album') or album album = fmt_tags.get('album') or album
title = fmt_tags.get('title') or title title = fmt_tags.get('title') or title
# Format does not have a stream index; we'll treat it as -1 if needed.
# Stream tags.
for idx, stream in enumerate(data.get('streams', [])): for idx, stream in enumerate(data.get('streams', [])):
stream_tags = stream.get('tags', {}) stream_tags = stream.get('tags', {})
# Album and title: update if present.
if 'album' in stream_tags: if 'album' in stream_tags:
album = stream_tags['album'] album = stream_tags['album']
if 'title' in stream_tags: if 'title' in stream_tags:
title = stream_tags['title'] title = stream_tags['title']
# Comment: collect all occurrences.
if 'comment' in stream_tags: if 'comment' in stream_tags:
comments.append((idx, stream_tags['comment'])) comments.append((idx, stream_tags['comment']))
return { return {'album': album, 'title': title, 'comments': comments}
'album': album,
'title': title,
'comments': comments
}
except (json.JSONDecodeError, KeyError): except (json.JSONDecodeError, KeyError):
return {'album': None, 'title': None, 'comments': []} return {'album': None, 'title': None, 'comments': []}
def get_container_format(input_file: str) -> Optional[str]:
"""
Retrieve the container format name (e.g., 'mp4', 'mp3', 'matroska') from the input file.
Args:
input_file: Path to the media file.
Returns:
Container format name (normalized) or None if detection fails.
"""
cmd = [
'ffprobe', '-v', 'error',
'-show_entries', 'format=format_name',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return None
format_name = result.stdout.strip().split(',')[0] # take first if multiple
if not format_name:
return None
# Normalize common aliases to names used in FORMAT_INFO
mapping = {
'mpeg': 'mp3',
'mp2': 'mp3',
'mp4': 'mp4',
'm4a': 'mp4',
'mov': 'mp4',
'3gp': 'mp4',
'matroska': 'matroska',
'webm': 'matroska',
'ogg': 'ogg',
'flac': 'flac',
'wav': 'wav',
'aac': 'aac',
'opus': 'opus',
'mp3': 'mp3',
'adts': 'aac',
'amr': 'amr',
}
return mapping.get(format_name, format_name)
def is_attached_picture(input_file: str) -> bool:
"""
Check if the input file has a video stream that is an attached picture (cover art).
Detection logic:
1. If there is a video stream with disposition.attached_pic == 1, return True.
2. Otherwise, if there is exactly one video stream and its codec is an image
format (PNG, MJPEG, JPEG, GIF, BMP), return True.
Returns:
True if a cover image is detected, False otherwise.
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-select_streams', 'v',
'-show_entries', 'stream=codec_name,disposition',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return False
try:
data = json.loads(result.stdout)
streams = data.get('streams', [])
if not streams:
return False
# Check each stream
for stream in streams:
codec = stream.get('codec_name', '').lower()
disposition = stream.get('disposition', {})
# If attached_pic is set, it's a cover image
if disposition.get('attached_pic') == 1:
return True
# If not, check if it's an image codec and we have exactly one video stream
if codec in ('png', 'mjpeg', 'jpeg', 'gif', 'bmp'):
# If there is exactly one video stream, treat it as cover
if len(streams) == 1:
return True
return False
except (json.JSONDecodeError, KeyError):
return False
def extract_cover_image(input_file: str, output_path: str) -> bool:
"""
Extract the first frame of the video stream (assumed to be an attached picture)
and save it to output_path.
Returns:
True if extraction succeeded, False otherwise.
"""
cmd = [
'ffmpeg', '-i', input_file,
'-map', '0:v:0',
'-frames:v', '1',
'-y',
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"Failed to extract cover image: {result.stderr}")
return False
return True
def build_ffmpeg_command(
input_file: str,
start_seconds: int,
duration_seconds: int,
output_path: str,
stream_info: Dict,
format_opt: Optional[str],
audio_codec: Optional[str] = 'copy',
video_codec: Optional[str] = 'copy',
subtitle_codec: Optional[str] = 'copy',
metadata: Optional[Dict] = None,
cover_image_path: Optional[str] = None,
video_quality: Optional[int] = None,
drop_video: bool = False,
drop_subs: bool = False,
) -> List[str]:
"""
Construct the FFmpeg command line as a list of arguments.
Args:
input_file: Path to the input media file.
start_seconds: Start time for the segment (in seconds).
duration_seconds: Duration of the segment (in seconds).
output_path: Destination path for the output file.
stream_info: Dictionary from get_stream_info().
format_opt: Output container format (e.g., 'mp3', 'mkv').
audio_codec: Audio codec to use ('copy' or encoder name like 'libopus').
video_codec: Video codec to use ('copy' or encoder name).
subtitle_codec: Subtitle codec to use ('copy' or encoder name).
metadata: Optional dict of metadata key/value pairs to write.
cover_image_path: Path to extracted cover image (if any).
video_quality: Quality value for video encoder (e.g., 1-31, lower=better).
drop_video: If True, remove video streams.
drop_subs: If True, remove subtitle streams.
Returns:
A list of commandline arguments suitable for subprocess.run().
"""
cmd = ['ffmpeg']
# Add cover image as first input if provided
if cover_image_path:
cmd.extend(['-i', cover_image_path])
# Add main input file
cmd.extend(['-i', input_file])
# Time options
cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)])
# Clear all original metadata.
cmd.append('-map_metadata')
cmd.append('-1')
# Apply custom metadata.
if metadata:
for key, value in metadata.items():
if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"])
# ---------- Stream mapping and codecs ----------
if cover_image_path:
# We have two inputs: index 0 = cover image, index 1 = main input
# Map audio from main input (index 1) and video from cover image (index 0)
cmd.extend(['-map', '1:a:0', '-map', '0:v:0'])
# Video codec: use user-specified codec if provided, otherwise fallback to png
if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', video_codec])
else:
cmd.extend(['-c:v', 'png'])
# Audio codec
if audio_codec and audio_codec != 'copy':
cmd.extend(['-c:a', audio_codec])
else:
cmd.extend(['-c:a', 'copy'])
# Subtitle: we don't copy subtitles when using cover image (they would be from main input)
cmd.append('-sn')
# Video quality if specified
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
else:
# Standard mapping (no cover image)
if drop_video and drop_subs:
cmd.extend(['-map', '0:a:0'])
elif drop_video:
cmd.extend(['-map', '0:a:0', '-map', '0:s?'])
elif drop_subs:
cmd.extend(['-map', '0:a:0', '-map', '0:v:0'])
else:
cmd.extend(['-map', '0'])
# Audio codec
if audio_codec and audio_codec != 'copy':
cmd.extend(['-c:a', audio_codec])
else:
cmd.extend(['-c:a', 'copy'])
# Video codec
if not drop_video and stream_info.get('has_video'):
if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', video_codec])
# Add video quality if specified (only when re-encoding)
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
else:
cmd.extend(['-c:v', 'copy'])
else:
cmd.append('-vn')
# Subtitle codec
if not drop_subs and stream_info.get('has_subtitle'):
if subtitle_codec and subtitle_codec != 'copy':
cmd.extend(['-c:s', subtitle_codec])
else:
cmd.extend(['-c:s', 'copy'])
else:
cmd.append('-sn')
# Output format
if format_opt:
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
cmd.extend(['-f', ffmpeg_format])
cmd.extend(['-y', output_path])
return cmd
+98 -26
View File
@@ -1,55 +1,127 @@
# audio_splitter/formats.py
"""Container format decision and validation.""" """Container format decision and validation."""
from .constants import FORMAT_INFO from typing import Dict, Optional
from .constants import (
FORMAT_INFO,
CONTAINER_INFO,
CODEC_TO_CONTAINER_MAP,
COMPATIBILITY_MATRIX,
is_audio_codec_supported,
is_video_codec_supported,
)
from .defaults import DEFAULT_FORMAT
def determine_output_format(stream_info, user_format, transcode_audio): def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
"""
Given the container format and audio codec, determine the recommended output format.
Uses the CODEC_TO_CONTAINER_MAP to map codec → container.
If the codec is not found, it falls back to the container.
Args:
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format().
codec: Audio codec name (e.g., 'opus', 'aac', 'mp3') as returned by get_audio_codec().
Returns:
Format name (e.g., 'opus', 'm4a', 'mp3') or None if unknown.
"""
if not container:
return None
if codec and codec in CODEC_TO_CONTAINER_MAP:
fmt = CODEC_TO_CONTAINER_MAP[codec]
if fmt in FORMAT_INFO:
return fmt
if container in FORMAT_INFO:
return container
return None
def determine_output_format(stream_info: Dict, user_format: Optional[str],
transcode_audio: Optional[str], input_file: Optional[str] = None) -> str:
""" """
Decide which container format to use. Decide which container format to use.
Args: If user_format is provided, use it.
stream_info: Dict from get_stream_info(). Else, try to detect the input file's container and codec, and use the recommended format.
user_format: Userrequested format (or None). If detection fails or format is not supported, fallback to:
transcode_audio: Audio codec to transcode to (or None). - MKV if video/subtitles exist
- MP3 if the audio codec is MP3
Returns: - MP4 (M4A) otherwise
A format name that exists in FORMAT_INFO.
""" """
if user_format: if user_format:
return user_format return user_format
# If video or subtitles exist, use MKV (which supports everything). if input_file:
if stream_info['has_video'] or stream_info['has_subtitle']: try:
return 'matroska' from .ffmpeg import get_container_format, get_audio_codec
container = get_container_format(input_file)
audio_codec = get_audio_codec(input_file)
fmt = determine_default_format(container, audio_codec)
if fmt in FORMAT_INFO:
return fmt
except Exception:
pass
# Audioonly: choose based on the current audio codec. # Fallback
if stream_info.get('has_video') or stream_info.get('has_subtitle'):
return 'matroska'
audio_codec = stream_info.get('audio_codec', '') audio_codec = stream_info.get('audio_codec', '')
if audio_codec == 'mp3': if audio_codec == 'mp3':
return 'mp3' return 'mp3'
else: else:
return 'mp4' # .m4a return 'mp4'
def validate_format_compatibility(format_name, stream_info, drop_video, drop_subs): def validate_format_compatibility(
container: str,
stream_info: Dict,
drop_video: bool,
drop_subs: bool,
input_file: Optional[str] = None,
audio_codec: Optional[str] = None,
video_codec: Optional[str] = None,
) -> None:
""" """
Ensure the chosen container can accommodate the streams we intend to keep. Ensure the chosen container and codec combination is valid.
Raises: Raises:
ValueError: If the format is incompatible with the intended streams. ValueError: If the combination is incompatible.
""" """
info = FORMAT_INFO.get(format_name) info = FORMAT_INFO.get(container)
if not info: if not info:
print(f"Warning: Unknown format '{format_name}'. Proceeding, but may fail.") print(f"Warning: Unknown container '{container}'. Proceeding, but may fail.")
return return
if info['audio_only']: # Check if container is audio-only and video is present (unless dropped)
if stream_info['has_video'] and not drop_video: if info['audio_only'] and stream_info.get('has_video') and not drop_video:
raise ValueError( raise ValueError(
f"Format '{format_name}' does not support video streams. " f"Container '{container}' does not support video streams. "
"Please use --drop-video or choose a container that supports video." "Please use --drop-video or choose a container that supports video (e.g., MKV, MP4)."
) )
if stream_info['has_subtitle'] and not drop_subs:
# Check if audio codec is supported
if audio_codec and audio_codec != 'copy':
if not is_audio_codec_supported(container, audio_codec):
raise ValueError( raise ValueError(
f"Format '{format_name}' does not support subtitle streams. " f"Container '{container}' does not support audio codec '{audio_codec}'. "
"Please use --drop-subs or choose a container that supports subtitles." f"Please choose a different container or audio codec."
)
# Check if video codec is supported (if video is present and not dropped)
if stream_info.get('has_video') and not drop_video:
# Determine the video codec from input file if not provided
if video_codec is None and input_file:
from .ffmpeg import get_video_codec
video_codec = get_video_codec(input_file)
if video_codec and video_codec != 'copy':
if not is_video_codec_supported(container, video_codec):
raise ValueError(
f"Container '{container}' does not support video codec '{video_codec}'. "
f"Please choose a different container, drop video, or transcode video to a supported codec."
) )
+116 -91
View File
@@ -1,139 +1,182 @@
"""Commandline interface and entry point.""" #!/usr/bin/env python3
"""
Audio Splitter CommandLine Interface
Split an audio file into tracks using a tracklist file.
Usage:
audio_splitter input.mp3 tracklist.txt [OPTIONS]
"""
import os import os
import sys import sys
import subprocess import subprocess
import argparse import argparse
from .constants import DEFAULT_BAD_CHARS from .defaults import (
from .tracklist import read_tracklist, parse_format DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE,
DEFAULT_REPLACEMENT_CHAR,
DEFAULT_BAD_CHARS,
DEFAULT_TRACKLIST_FORMAT,
DEFAULT_ALBUM,
DEFAULT_COMMENT,
DEFAULT_COMMENT_STREAM,
DEFAULT_COMMENT_SEPARATOR,
DEFAULT_NO_COMMENT,
DEFAULT_MERGE_COMMENTS,
DEFAULT_DROP_VIDEO,
DEFAULT_DROP_SUBS,
DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING,
DEFAULT_DELETE_ORIGINAL,
)
from .core import split_audio from .core import split_audio
from .tracklist import read_tracklist, parse_format
def main(): def main():
"""Parse arguments and start the splitting process."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Split an audio file into tracks using a tracklist.", description="Split an audio file into tracks using a tracklist.",
epilog="Tracklist format: mm:ss track_name - author_name" epilog="Tracklist format: mm:ss track_name - author_name (or custom with --tracklist-format)"
) )
# Positional arguments. # Positional
parser.add_argument('input_file', help='Input audio/video file') parser.add_argument('input_file', help='Input audio file')
parser.add_argument('tracklist_file', help='Tracklist file') parser.add_argument('tracklist_file', help='Tracklist file')
# Optional arguments. # Container and codec options
parser.add_argument( parser.add_argument(
'--output-dir', '-o', '--container',
help='Output directory for split tracks (default: <input_basename>_splits)' default=None,
help="Output container format (auto-detect if not specified)"
) )
parser.add_argument( parser.add_argument(
'--format', '--audio-codec',
help='Output container format (e.g., mp3, m4a, mkv, mp4, ogg, opus)' default='copy',
help="Audio codec (copy or encoder name, e.g., libopus)"
) )
parser.add_argument( parser.add_argument(
'--transcode-to', '--video-codec',
help='Re-encode audio to this codec (e.g., libmp3lame, aac, libopus)' default='copy',
help="Video codec (copy or encoder name, e.g., libx264)"
)
parser.add_argument(
'--subtitle-codec',
default='copy',
help="Subtitle codec (copy or encoder name, e.g., srt)"
)
parser.add_argument(
'--video-quality', '-vq',
type=int,
default=None,
help="Video quality (integer, encoder-specific; usually 1-31, lower=better). "
"If omitted, FFmpeg default is used."
) )
parser.add_argument( parser.add_argument(
'--drop-video', '--drop-video',
action='store_true', action='store_true',
help='Remove video streams from output' default=DEFAULT_DROP_VIDEO,
help="Drop video streams"
) )
parser.add_argument( parser.add_argument(
'--drop-subs', '--drop-subs',
action='store_true', action='store_true',
help='Remove subtitle streams from output' default=DEFAULT_DROP_SUBS,
help="Drop subtitle streams"
) )
# Filename options
parser.add_argument( parser.add_argument(
'--number-tracks', '--number-tracks',
action='store_true', action='store_true',
help='Prepend track number to output filenames (convenience; use %%num in template for full control)' default=DEFAULT_NUMBER_TRACKS,
help="Prepend track numbers"
)
parser.add_argument(
'--output-template',
default=DEFAULT_OUTPUT_TEMPLATE,
help="Output filename template (default: %(default)s)"
) )
parser.add_argument( parser.add_argument(
'--replace-bad-chars', '--replace-bad-chars',
action='store_true', action='store_true',
help='Replace problematic characters in filenames (default: off)' default=DEFAULT_REPLACE_BAD_CHARS,
help="Replace bad characters"
) )
parser.add_argument( parser.add_argument(
'--replacement-char', '--replacement-char',
default='_', default=DEFAULT_REPLACEMENT_CHAR,
help='Character used as replacement (default: "_")' help="Replacement character (default: %(default)s)"
) )
parser.add_argument( parser.add_argument(
'--bad-chars', '--bad-chars',
default=DEFAULT_BAD_CHARS, default=DEFAULT_BAD_CHARS,
help='String of characters to replace (default includes space and single quote)' help="Bad characters to replace (default: %(default)s)"
) )
parser.add_argument( parser.add_argument(
'--skip-existing', '--skip-existing',
action='store_true', action='store_true',
help='Skip extraction if output file already exists (default: overwrite)' default=DEFAULT_SKIP_EXISTING,
) help="Skip existing output files"
parser.add_argument(
'--tracklist-format',
default='%ts %tn - %an',
help='Format of each line in the tracklist using placeholders: '
'%%ts (timestamp), %%tn (track name), %%an (author), %%al (album), '
'%%date (date/year), %%ext (file extension). '
'Default: "%%ts %%tn - %%an"'
)
parser.add_argument(
'--output-template',
default='%an-%tn.%ext',
help='Template for output filenames using placeholders: '
'%%tn (track name), %%an (author), %%al (album), '
'%%date (date/year), %%ext (file extension), %%num (track number). '
'Default: "%%an-%%tn.%%ext"'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Parse and display the tracklist without splitting any files'
) )
# Metadata options. # Metadata
parser.add_argument( parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name")
'--album', parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment")
help='Set album name in output metadata (overrides parsed %%al and original album)'
)
parser.add_argument(
'--comment',
help='Explicit comment text (overrides all other comment settings)'
)
parser.add_argument( parser.add_argument(
'--no-comment', '--no-comment',
action='store_true', action='store_true',
help='Explicitly ignore any comment (no comment written)' default=DEFAULT_NO_COMMENT,
help="Ignore comment"
) )
parser.add_argument( parser.add_argument(
'--comment-stream', '--comment-stream',
type=int, type=int,
default=None, default=DEFAULT_COMMENT_STREAM,
help='Select comment from a specific stream index (0based). Default: first stream with a comment.' help="Comment stream index"
) )
parser.add_argument( parser.add_argument(
'--merge-comments', '--merge-comments',
action='store_true', action='store_true',
help='Merge all comments from all streams into one (separated by --comment-separator)' default=DEFAULT_MERGE_COMMENTS,
help="Merge all comments"
) )
parser.add_argument( parser.add_argument(
'--comment-separator', '--comment-separator',
default='; ', default=DEFAULT_COMMENT_SEPARATOR,
help='Separator used when merging comments (default: "; ")' help="Separator for merged comments (default: %(default)s)"
) )
# NEW: Delete original after successful split. # Tracklist format
parser.add_argument(
'--tracklist-format',
default=DEFAULT_TRACKLIST_FORMAT,
help="Tracklist format (default: %(default)s)"
)
# Other
parser.add_argument( parser.add_argument(
'--delete-original', '--delete-original',
action='store_true', action='store_true',
help='Delete the original input file after successful splitting (default: keep)' default=DEFAULT_DELETE_ORIGINAL,
help="Delete original file after split"
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Parse and display tracklist without splitting"
)
parser.add_argument(
'--output-dir', '-o',
default=None,
help="Output directory (default: <input_basename>_splits)"
) )
args = parser.parse_args() args = parser.parse_args()
# -------------------------------------------------------------------------- # Validate input
# Input validation
# --------------------------------------------------------------------------
if not os.path.exists(args.input_file): if not os.path.exists(args.input_file):
print(f"Error: Input file not found: {args.input_file}") print(f"Error: Input file not found: {args.input_file}")
sys.exit(1) sys.exit(1)
@@ -142,12 +185,7 @@ def main():
print(f"Error: Tracklist file not found: {args.tracklist_file}") print(f"Error: Tracklist file not found: {args.tracklist_file}")
sys.exit(1) sys.exit(1)
# Ensure the replacement character is a single character. # Check FFmpeg
if len(args.replacement_char) != 1:
print("Error: --replacement-char must be a single character.")
sys.exit(1)
# Check that FFmpeg is installed.
try: try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True) subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
@@ -155,14 +193,14 @@ def main():
print(" - https://ffmpeg.org/download.html") print(" - https://ffmpeg.org/download.html")
sys.exit(1) sys.exit(1)
# Parse the tracklist using the userprovided format. # Parse tracklist format
try: try:
tokens = parse_format(args.tracklist_format) tokens = parse_format(args.tracklist_format)
except ValueError as error: except ValueError as e:
print(f"Error in --tracklist-format: {error}") print(f"Error in --tracklist-format: {e}")
sys.exit(1) sys.exit(1)
# Read and parse the tracklist file. # Read tracklist
tracks = read_tracklist(args.tracklist_file, tokens) tracks = read_tracklist(args.tracklist_file, tokens)
if not tracks: if not tracks:
print("Error: No valid tracks found in tracklist file.") print("Error: No valid tracks found in tracklist file.")
@@ -170,7 +208,6 @@ def main():
print(f"Found {len(tracks)} tracks.") print(f"Found {len(tracks)} tracks.")
# Dryrun mode: display parsed data and exit.
if args.dry_run: if args.dry_run:
print("\nParsed tracklist:") print("\nParsed tracklist:")
print("-" * 60) print("-" * 60)
@@ -184,34 +221,22 @@ def main():
print(f"{idx:3d} | " + " | ".join(values)) print(f"{idx:3d} | " + " | ".join(values))
print("-" * 60) print("-" * 60)
print("Dryrun complete. No files were created.") print("Dryrun complete. No files were created.")
sys.exit(0) return
# Determine the output directory. # Determine output directory
if args.output_dir: if args.output_dir:
output_dir = args.output_dir output_dir = args.output_dir
print(f"Using custom output directory: {output_dir}")
else: else:
base_name = os.path.splitext(os.path.basename(args.input_file))[0] base_name = os.path.splitext(os.path.basename(args.input_file))[0]
output_dir = base_name + "_splits" output_dir = base_name + "_splits"
print(f"Using default output directory: {output_dir}")
# Run the splitter. # Run split
try: try:
split_audio(args.input_file, output_dir, tracks, args) split_audio(args.input_file, output_dir, tracks, args)
except (RuntimeError, ValueError) as error: except Exception as e:
print(f"Error: {error}") print(f"Error during split: {e}")
sys.exit(1) sys.exit(1)
# --------------------------------------------------------------------------
# Delete original file if requested and successful.
# --------------------------------------------------------------------------
if args.delete_original:
try:
os.remove(args.input_file)
print(f"Deleted original file: {args.input_file}")
except OSError as e:
print(f"Warning: Could not delete original file: {e}")
print("Done!") print("Done!")
-37
View File
@@ -1,37 +0,0 @@
services:
backend:
build:
context: .
dockerfile: web/backend/Dockerfile
volumes:
# Bind mount for source code (hot-reload for Python)
- ./web/backend:/app/backend
- ./audio_splitter:/app/audio_splitter
- ./setup.py:/app/setup.py
- ./pyproject.toml:/app/pyproject.toml
# Development data directory (overrides production)
- "./dev_data:/tmp/audio_splitter_web"
environment:
- DEBUG=1
- PYTHONUNBUFFERED=1
ports:
- "8000:8000"
frontend:
build:
context: ./web/frontend
# Use the same Dockerfile, but override CMD for development
dockerfile: Dockerfile
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
volumes:
# Bind mount for source code (hot-reload for Vite)
- ./web/frontend:/app
- node_modules:/app/node_modules
environment:
- BACKEND_URL=http://backend:8000
- VITE_BACKEND_URL=http://backend:8000
ports:
- "5173:5173"
volumes:
node_modules:
+10 -5
View File
@@ -1,19 +1,24 @@
services: services:
backend: backend:
image: git.vmn.su/max/audio_splitter_backend:latest build:
context: .
dockerfile: web/backend/Dockerfile
#image: max/audio_splitter_backend:latest
ports: ports:
- "${BACKEND_PORT:-8000}:8000" - "${BACKEND_PORT:-8000}:8000"
volumes: volumes:
# Bind mount for persistent data storage. #- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
# Set BACKEND_DATA_DIR in .env to point to your data directory. - "/tmp/test:/tmp/audio_splitter_web"
- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DEBUG=${DEBUG:-0} - DEBUG=${DEBUG:-0}
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: git.vmn.su/max/audio_splitter_frontend:latest build:
context: .
dockerfile: web/frontend/Dockerfile
#image: max/audio_splitter_frontend:latest
ports: ports:
- "${FRONTEND_PORT:-5173}:80" - "${FRONTEND_PORT:-5173}:80"
depends_on: depends_on:
-18
View File
@@ -1,18 +0,0 @@
#!/bin/bash
set -e
# Check if we are running as root (default)
if [ "$(id -u)" = "0" ]; then
# If /data is a directory, change its ownership to the container user
if [ -d "/data" ]; then
echo "Setting ownership of /data to appuser:appgroup"
chown -R appuser:appgroup /data
fi
# Drop privileges and run audio_splitter with the provided arguments
exec gosu appuser audio_splitter "$@"
else
# If not root, just run audio_splitter directly
# It's not supposed to be called
exec audio_splitter "$@"
fi
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Generate TypeScript constants from audio_splitter/defaults.py.
"""
import importlib.util
import json
import sys
from pathlib import Path
# Get the directory where this script is located
SCRIPT_DIR = Path(__file__).parent.absolute()
# The defaults.py is in the same directory as the script
DEFAULTS_PATH = SCRIPT_DIR / "defaults.py"
# The frontend source is at SCRIPT_DIR.parent (i.e., /app)
# The generated file should be at /app/src/constants/generated.ts
FRONTEND_ROOT = SCRIPT_DIR.parent
OUTPUT_PATH = FRONTEND_ROOT / "src" / "constants" / "generated.ts"
def load_defaults_module():
spec = importlib.util.spec_from_file_location("defaults", DEFAULTS_PATH)
if spec is None:
raise RuntimeError(f"Could not load spec for {DEFAULTS_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def format_value(value):
"""Convert Python value to TypeScript literal."""
if value is None:
return "null"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, str):
# Use json.dumps to produce a properly escaped string literal
return json.dumps(value)
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, list):
return f"[{', '.join(format_value(v) for v in value)}]"
return repr(value)
def main():
print(f"Generating TypeScript defaults from {DEFAULTS_PATH}")
defaults = load_defaults_module()
constants = {name: value for name, value in vars(defaults).items() if name.startswith("DEFAULT_")}
if not constants:
print("No DEFAULT_* constants found.")
sys.exit(1)
lines = [
"// ============================================================================",
"// GENERATED FILE DO NOT EDIT MANUALLY.",
"// This file is generated from audio_splitter/defaults.py.",
"// Run `npm run generate` or `python scripts/generate_ts_defaults.py` to update.",
"// ============================================================================",
"",
]
for name, value in sorted(constants.items()):
ts_value = format_value(value)
lines.append(f"export const {name} = {ts_value};")
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text("\n".join(lines) + "\n")
print(f"✅ Generated {OUTPUT_PATH}")
print(f" {len(constants)} constants exported.")
if __name__ == "__main__":
main()
+1 -2
View File
@@ -1,4 +1,3 @@
"""API route handlers.""" """API route handlers."""
from . import upload, split, status, download from . import upload, split, split_file, status, download, websocket, formats, info
# websocket is imported directly in main.py to avoid circular import
+5 -11
View File
@@ -1,8 +1,9 @@
# web/backend/api/formats.py
"""Endpoint to expose format information to the frontend.""" """Endpoint to expose format information to the frontend."""
from fastapi import APIRouter from fastapi import APIRouter
from backend.constants import FORMAT_INFO from audio_splitter.constants import CONTAINER_INFO, CODEC_INFO
router = APIRouter(prefix="/api", tags=["formats"]) router = APIRouter(prefix="/api", tags=["formats"])
@@ -10,16 +11,9 @@ router = APIRouter(prefix="/api", tags=["formats"])
@router.get("/formats") @router.get("/formats")
async def get_formats(): async def get_formats():
""" """
Return the list of supported container formats with their properties. Return the list of supported containers and codecs.
""" """
return { return {
"formats": [ "containers": CONTAINER_INFO,
{ "codecs": CODEC_INFO,
"name": name,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
} }
+39 -2
View File
@@ -5,7 +5,12 @@ from fastapi import APIRouter, HTTPException
from backend.config import settings from backend.config import settings
from backend.services.file_manager import FileManager from backend.services.file_manager import FileManager
from backend.services.task_manager import task_manager from backend.services.task_manager import task_manager
from backend.ffmpeg import get_stream_info
# Import core functions
from audio_splitter.ffmpeg import get_stream_info, get_container_format, get_audio_codec
from audio_splitter.formats import determine_default_format
from audio_splitter.constants import FORMAT_INFO
from audio_splitter.defaults import DEFAULT_FORMAT
router = APIRouter(prefix="/api", tags=["info"]) router = APIRouter(prefix="/api", tags=["info"])
@@ -13,7 +18,8 @@ router = APIRouter(prefix="/api", tags=["info"])
@router.get("/info/{task_id}") @router.get("/info/{task_id}")
async def get_task_info(task_id: str): async def get_task_info(task_id: str):
""" """
Return stream information (has_audio, has_video, has_subtitle) for the uploaded file. Return stream information (has_audio, has_video, has_subtitle, audio_codec)
for the uploaded file.
""" """
if not task_manager.has_task(task_id): if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found") raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
@@ -33,3 +39,34 @@ async def get_task_info(task_id: str):
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
@router.get("/info/recommended-format/{task_id}")
async def get_recommended_format(task_id: str):
"""
Return the recommended output format (container name) for the uploaded file,
based on its container and audio codec.
"""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
raise HTTPException(status_code=404, detail="Input file not found")
try:
container = get_container_format(str(input_path))
codec = get_audio_codec(str(input_path))
fmt = determine_default_format(container, codec)
# Fallback if detection fails or format is unsupported
if fmt is None:
fmt = DEFAULT_FORMAT
if fmt not in 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)}")
+30 -2
View File
@@ -1,4 +1,4 @@
"""Split task endpoint.""" """Split task endpoint (JSON tracklist)."""
from fastapi import APIRouter, HTTPException, BackgroundTasks from fastapi import APIRouter, HTTPException, BackgroundTasks
@@ -24,6 +24,29 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
detail=f"Task {task_id} is already {status['status']}" detail=f"Task {task_id} is already {status['status']}"
) )
# Build options dict from request
options = {
"container": request.container,
"audio_codec": request.audio_codec,
"video_codec": request.video_codec,
"subtitle_codec": request.subtitle_codec,
"video_quality": request.video_quality,
"drop_video": request.drop_video,
"drop_subs": request.drop_subs,
"number_tracks": request.number_tracks,
"replace_bad_chars": request.replace_bad_chars,
"replacement_char": request.replacement_char,
"bad_chars": request.bad_chars,
"skip_existing": request.skip_existing,
"output_template": request.output_template,
"album": request.album,
"comment": request.comment,
"no_comment": request.no_comment,
"comment_stream": request.comment_stream,
"merge_comments": request.merge_comments,
"comment_separator": request.comment_separator,
}
task_manager.update_task( task_manager.update_task(
task_id, task_id,
status=TaskStatus.PROCESSING, status=TaskStatus.PROCESSING,
@@ -31,7 +54,12 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
message="Preparing to split..." message="Preparing to split..."
) )
background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options) background_tasks.add_task(
run_split_task,
task_id,
request.tracklist,
options
)
return SplitResponse( return SplitResponse(
task_id=task_id, task_id=task_id,
+151
View File
@@ -0,0 +1,151 @@
"""Endpoint for splitting with a tracklist file upload."""
import json
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.services.splitter import run_split_task
from backend.models.request import TracklistEntry
from backend.models.response import SplitResponse, TaskStatus
router = APIRouter(prefix="/api", tags=["split"])
from audio_splitter.tracklist import parse_format, read_tracklist
@router.post("/split-file", response_model=SplitResponse)
async def split_from_file(
background_tasks: BackgroundTasks,
task_id: str = Form(...),
tracklist_file: UploadFile = File(...),
# New fields
container: Optional[str] = Form(None),
audio_codec: str = Form("copy"),
video_codec: str = Form("copy"),
subtitle_codec: str = Form("copy"),
video_quality: Optional[int] = Form(None),
drop_video: bool = Form(False),
drop_subs: bool = Form(False),
number_tracks: bool = Form(False),
replace_bad_chars: bool = Form(False),
replacement_char: str = Form("_"),
bad_chars: str = Form(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
skip_existing: bool = Form(False),
output_template: str = Form("%an-%tn.%ext"),
album: Optional[str] = Form(None),
comment: Optional[str] = Form(None),
no_comment: bool = Form(False),
comment_stream: Optional[int] = Form(None),
merge_comments: bool = Form(False),
comment_separator: str = Form("; "),
options: str = Form("{}"), # backward-compatible, but we now use explicit fields
tracklist_format: str = Form("%ts %tn - %an"),
):
# Validate task exists
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
raise HTTPException(
status_code=409,
detail=f"Task {task_id} is already {status['status']}"
)
if not tracklist_file.filename.endswith(('.txt', '.text')):
raise HTTPException(
status_code=400,
detail="Tracklist file must be a text file (.txt or .text)"
)
try:
content = await tracklist_file.read()
text = content.decode('utf-8')
except UnicodeDecodeError:
raise HTTPException(
status_code=400,
detail="Tracklist file must be UTF-8 encoded"
)
if not text.strip():
raise HTTPException(
status_code=400,
detail="Tracklist file is empty"
)
# Parse tracklist using CLI logic
try:
tokens = parse_format(tracklist_format)
temp_tracklist_path = settings.temp_dir / task_id / "tracklist.txt"
temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True)
temp_tracklist_path.write_text(text, encoding='utf-8')
tracklist_dicts = read_tracklist(str(temp_tracklist_path), tokens)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to parse tracklist: {str(e)}"
)
if not tracklist_dicts:
raise HTTPException(
status_code=400,
detail="No valid tracks found in tracklist file"
)
# Convert dicts to TracklistEntry objects
try:
tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts]
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Invalid tracklist data: {str(e)}"
)
# Build options dict from explicit fields (ignore `options` parameter)
options = {
"container": container,
"audio_codec": audio_codec,
"video_codec": video_codec,
"subtitle_codec": subtitle_codec,
"video_quality": video_quality,
"drop_video": drop_video,
"drop_subs": drop_subs,
"number_tracks": number_tracks,
"replace_bad_chars": replace_bad_chars,
"replacement_char": replacement_char,
"bad_chars": bad_chars,
"skip_existing": skip_existing,
"output_template": output_template,
"album": album,
"comment": comment,
"no_comment": no_comment,
"comment_stream": comment_stream,
"merge_comments": merge_comments,
"comment_separator": comment_separator,
}
# Update task status
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Preparing to split..."
)
background_tasks.add_task(
run_split_task,
task_id,
tracklist_entries,
options
)
return SplitResponse(
task_id=task_id,
status=TaskStatus.PROCESSING
)
+4 -3
View File
@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from backend.config import settings from backend.config import settings
from backend.api import upload, split, status, download, websocket, formats, info from backend.api import upload, split, split_file, status, download, websocket, formats, info
from backend.services import progress_publisher from backend.services import progress_publisher
app = FastAPI( app = FastAPI(
@@ -33,11 +33,12 @@ async def startup_event():
# Include routers # Include routers
app.include_router(upload.router) app.include_router(upload.router)
app.include_router(split.router) app.include_router(split.router)
app.include_router(split_file.router) # NEW
app.include_router(status.router) app.include_router(status.router)
app.include_router(download.router) app.include_router(download.router)
app.include_router(websocket.router) app.include_router(websocket.router)
app.include_router(formats.router) # new app.include_router(formats.router)
app.include_router(info.router) # new app.include_router(info.router)
@app.get("/") @app.get("/")
+27 -5
View File
@@ -17,19 +17,16 @@ class TracklistEntry(BaseModel):
@validator("ts") @validator("ts")
def validate_timestamp(cls, v: str) -> str: def validate_timestamp(cls, v: str) -> str:
"""Basic timestamp validation (format and range)."""
v = v.strip() v = v.strip()
if not v: if not v:
raise ValueError("Timestamp cannot be empty") raise ValueError("Timestamp cannot be empty")
# Check for range format (start-end)
if "-" in v: if "-" in v:
parts = v.split("-", 1) parts = v.split("-", 1)
start = parts[0].strip() start = parts[0].strip()
end = parts[1].strip() end = parts[1].strip()
if not start or not end: if not start or not end:
raise ValueError("Invalid range format. Expected 'start-end'") raise ValueError("Invalid range format. Expected 'start-end'")
# Validate each part with the same logic
for ts in [start, end]: for ts in [start, end]:
cls._validate_single_timestamp(ts) cls._validate_single_timestamp(ts)
else: else:
@@ -39,7 +36,6 @@ class TracklistEntry(BaseModel):
@staticmethod @staticmethod
def _validate_single_timestamp(ts: str) -> None: def _validate_single_timestamp(ts: str) -> None:
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
parts = ts.split(":") parts = ts.split(":")
if len(parts) not in (2, 3): if len(parts) not in (2, 3):
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS") raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS")
@@ -55,4 +51,30 @@ class SplitRequest(BaseModel):
task_id: str = Field(..., description="Task ID from upload") task_id: str = Field(..., description="Task ID from upload")
tracklist: List[TracklistEntry] = Field(..., description="List of tracks") tracklist: List[TracklistEntry] = Field(..., description="List of tracks")
options: dict = Field(default_factory=dict, description="All CLI options")
# Container and codec options
container: Optional[str] = Field(None, description="Output container (auto-detect if None)")
audio_codec: Optional[str] = Field("copy", description="Audio codec (copy or encoder name)")
video_codec: Optional[str] = Field("copy", description="Video codec (copy or encoder name)")
subtitle_codec: Optional[str] = Field("copy", description="Subtitle codec (copy or encoder name)")
video_quality: Optional[int] = Field(None, description="Video quality (encoder-specific integer)")
# Stream handling
drop_video: bool = Field(False, description="Remove video streams")
drop_subs: bool = Field(False, description="Remove subtitle streams")
# Filename options
number_tracks: bool = Field(False, description="Prepend track numbers")
replace_bad_chars: bool = Field(False, description="Replace bad characters")
replacement_char: str = Field("_", description="Replacement character")
bad_chars: str = Field(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ', description="Bad characters to replace")
skip_existing: bool = Field(False, description="Skip existing files")
output_template: str = Field("%an-%tn.%ext", description="Output filename template")
# Metadata options
album: Optional[str] = Field(None, description="Album name")
comment: Optional[str] = Field(None, description="Comment")
no_comment: bool = Field(False, description="Ignore comment")
comment_stream: Optional[int] = Field(None, description="Comment stream index")
merge_comments: bool = Field(False, description="Merge all comments")
comment_separator: str = Field("; ", description="Separator for merged comments")
+22 -7
View File
@@ -40,9 +40,13 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from types import SimpleNamespace from types import SimpleNamespace
# Build args namespace using the new fields
args = SimpleNamespace( args = SimpleNamespace(
format=options.get("format", "mp3"), container=options.get("container"), # None -> auto-detect
transcode_to=options.get("transcode_to", None), audio_codec=options.get("audio_codec", "copy"),
video_codec=options.get("video_codec", "copy"),
subtitle_codec=options.get("subtitle_codec", "copy"),
video_quality=options.get("video_quality"),
drop_video=options.get("drop_video", False), drop_video=options.get("drop_video", False),
drop_subs=options.get("drop_subs", False), drop_subs=options.get("drop_subs", False),
number_tracks=options.get("number_tracks", False), number_tracks=options.get("number_tracks", False),
@@ -66,17 +70,30 @@ 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,
@@ -84,13 +101,11 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
status=TaskStatus.DONE status=TaskStatus.DONE
) )
# Add tracks to the task state
task_manager.update_task( task_manager.update_task(
task_id, task_id,
tracks=output_files tracks=output_files
) )
# Give WebSocket time to send the final message
time.sleep(0.5) time.sleep(0.5)
except Exception as e: except Exception as e:
+27 -12
View File
@@ -1,27 +1,42 @@
# Stage 1: Build # web/frontend/Dockerfile
# Build context must be the project root.
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
# Install Python for the generation script
RUN apk add --no-cache python3 py3-pip
WORKDIR /app WORKDIR /app
# Install dependencies # Copy frontend package files and install dependencies
COPY package.json package-lock.json* ./ COPY web/frontend/package.json web/frontend/package-lock.json* ./
RUN npm install RUN npm install
# Copy the application code and build # Copy the frontend source code
COPY . . COPY web/frontend/ .
# Copy the Python defaults and the generation script into the expected location
# The generate script in package.json expects ../../scripts/generate_ts_defaults.py
# So we must place it at /app/../../scripts/ which is /scripts/
# But we can't COPY to a parent directory. Instead, we'll copy to /app/scripts/
# and adjust the package.json script to use ./scripts/generate_ts_defaults.py
# Actually, the simplest fix is to copy to /app/scripts/ and then adjust package.json.
#
# Let's use a different approach: copy to /app/scripts/ and update the generate script.
COPY audio_splitter/defaults.py /app/scripts/defaults.py
COPY scripts/generate_ts_defaults.py /app/scripts/generate_ts_defaults.py
# Run the generation script
RUN python /app/scripts/generate_ts_defaults.py
# Build the frontend
RUN npm run build RUN npm run build
# Stage 2: Production (nginx) # Stage 2: Production (nginx)
FROM nginx:alpine FROM nginx:alpine
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
COPY web/frontend/nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Copy nginx configuration
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Expose the port
EXPOSE 80 EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+3
View File
@@ -4,6 +4,9 @@
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"generate": "python scripts/generate_ts_defaults.py",
"predev": "npm run generate",
"prebuild": "npm run generate",
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+7 -8
View File
@@ -68,16 +68,15 @@ const App: React.FC = () => {
reset() reset()
} }
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError const isSplitDisabled =
!taskId ||
!isValid ||
entries.length === 0 ||
isProcessing ||
!!formatError
return ( return (
<ThemeProvider <ThemeProvider theme={createTheme({ palette: { mode: theme } })}>
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<CssBaseline /> <CssBaseline />
<Layout> <Layout>
<Grid container spacing={3}> <Grid container spacing={3}>
+39 -10
View File
@@ -1,5 +1,12 @@
import axios from 'axios' import axios from 'axios'
import { TracklistEntry, SplitOptions, TaskStatus } from '../types' import {
TracklistEntry,
SplitOptions,
TaskStatus,
FormatsResponse,
UploadResponse,
SplitResponse,
} from '../types'
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', baseURL: '/api',
@@ -8,16 +15,14 @@ export const api = axios.create({
}, },
}) })
export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => { export const uploadFile = async (file: File): Promise<UploadResponse> => {
const formData = new FormData() const formData = new FormData()
formData.append('file', file) formData.append('file', file)
const response = await api.post('/upload', formData, { const response = await api.post('/upload', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}) })
return response.data return response.data
} }
@@ -25,12 +30,32 @@ export const startSplit = async (
task_id: string, task_id: string,
tracklist: TracklistEntry[], tracklist: TracklistEntry[],
options: SplitOptions options: SplitOptions
): Promise<{ task_id: string; status: string }> => { ): Promise<SplitResponse> => {
const response = await api.post('/split', { // Build request payload (only send fields that are not undefined)
const payload: any = {
task_id, task_id,
tracklist, tracklist,
options, container: options.container, // null means auto-detect
}) audio_codec: options.audio_codec,
video_codec: options.video_codec,
subtitle_codec: options.subtitle_codec,
video_quality: options.video_quality,
drop_video: options.drop_video,
drop_subs: options.drop_subs,
number_tracks: options.number_tracks,
replace_bad_chars: options.replace_bad_chars,
replacement_char: options.replacement_char,
bad_chars: options.bad_chars,
skip_existing: options.skip_existing,
output_template: options.output_template,
album: options.album || null,
comment: options.comment || null,
no_comment: options.no_comment,
comment_stream: options.comment_stream,
merge_comments: options.merge_comments,
comment_separator: options.comment_separator,
}
const response = await api.post('/split', payload)
return response.data return response.data
} }
@@ -47,8 +72,7 @@ export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip` return `/api/download/${task_id}/splits.zip`
} }
// New functions for format validation feature export const getFormats = async (): Promise<FormatsResponse> => {
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
const response = await api.get('/formats') const response = await api.get('/formats')
return response.data return response.data
} }
@@ -63,3 +87,8 @@ export const getTaskInfo = async (task_id: string): Promise<{
const response = await api.get(`/info/${task_id}`) const response = await api.get(`/info/${task_id}`)
return response.data return response.data
} }
export const getRecommendedFormat = async (task_id: string): Promise<{ format: string }> => {
const response = await api.get(`/info/recommended-format/${task_id}`)
return response.data
}
+186 -73
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react' import React, { useEffect, useMemo } from 'react'
import { import {
Box, Box,
Paper, Paper,
@@ -16,7 +16,12 @@ import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore' import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore' import { useValidationStore } from '../stores/validationStore'
import { getFormats } from '../api/client'
import { SplitOptions } from '../types'
// ------------------------------------------------------------------------------
// Section component (collapsible)
// ------------------------------------------------------------------------------
interface SectionProps { interface SectionProps {
title: string title: string
children: React.ReactNode children: React.ReactNode
@@ -25,7 +30,6 @@ interface SectionProps {
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => { const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded) const [expanded, setExpanded] = React.useState(defaultExpanded)
return ( return (
<Box sx={{ mb: 2 }}> <Box sx={{ mb: 2 }}>
<Box <Box
@@ -51,54 +55,116 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
) )
} }
// ------------------------------------------------------------------------------
// Main OptionsPanel
// ------------------------------------------------------------------------------
export const OptionsPanel: React.FC = () => { export const OptionsPanel: React.FC = () => {
const { options, setOptions } = useOptionsStore() const {
options,
setOptions,
containers,
codecs,
compatibility,
setContainers,
setCodecs,
setCompatibility,
} = useOptionsStore()
const { hasVideo } = useUploadStore() const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore() const { formatError, setFormatError } = useValidationStore()
// Audio-only formats from backend constants (hardcoded for now) // Fetch formats on mount
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFormat = e.target.value
setOptions({ format: newFormat })
// Validate format
if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
setFormatError(
`Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
}
}
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked
setOptions({ drop_video: checked })
// Re-validate format
if (audioOnlyFormats.includes(options.format) && hasVideo && !checked) {
setFormatError(
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
}
}
// Re-validate when hasVideo changes (e.g., after upload)
useEffect(() => { useEffect(() => {
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video const fetchFormats = async () => {
const newError = shouldShowError try {
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).` const data = await getFormats()
: null setContainers(data.containers || [])
setCodecs(data.codecs || [])
// Only update if the error state actually changes setCompatibility(data.compatibility || {})
if (newError !== formatError) { } catch (err) {
setFormatError(newError) console.error('Failed to fetch formats:', err)
} }
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError]) }
fetchFormats()
}, [setContainers, setCodecs, setCompatibility])
// --------------------------------------------------------------
// Filter codec options based on selected container
// --------------------------------------------------------------
const filteredAudioCodecs = useMemo(() => {
const entry = compatibility?.[options.container || '']
if (!entry) return codecs.filter(c => c.supports_transcoding)
const audioList = entry.audio
if (audioList === null) return codecs.filter(c => c.supports_transcoding)
return codecs.filter(c => c.supports_transcoding && audioList.includes(c.name))
}, [compatibility, options.container, codecs])
const filteredVideoCodecs = useMemo(() => {
const entry = compatibility?.[options.container || '']
if (!entry) return codecs.filter(c => c.supports_video)
const videoList = entry.video
if (videoList === null) return codecs.filter(c => c.supports_video)
return codecs.filter(c => c.supports_video && videoList.includes(c.name))
}, [compatibility, options.container, codecs])
// --------------------------------------------------------------
// Validate compatibility
// --------------------------------------------------------------
useEffect(() => {
const selectedContainer = containers.find(c => c.name === options.container)
if (selectedContainer?.audio_only && hasVideo && !options.drop_video) {
setFormatError(
`Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.`
)
return
}
// Check audio codec compatibility
if (options.audio_codec && options.audio_codec !== 'copy' && options.container) {
const entry = compatibility?.[options.container]
if (entry) {
const audioList = entry.audio
if (audioList !== null && !audioList.includes(options.audio_codec)) {
setFormatError(
`Audio codec '${options.audio_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
// Check video codec compatibility
if (options.video_codec && options.video_codec !== 'copy' && options.container && hasVideo && !options.drop_video) {
const entry = compatibility?.[options.container]
if (entry) {
const videoList = entry.video
if (videoList !== null && !videoList.includes(options.video_codec)) {
setFormatError(
`Video codec '${options.video_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
setFormatError(null)
}, [options.container, options.audio_codec, options.video_codec, options.drop_video, hasVideo, compatibility, containers, setFormatError])
// --------------------------------------------------------------
// Handlers
// --------------------------------------------------------------
const handleChange = (field: keyof SplitOptions, value: any) => {
setOptions({ [field]: value })
}
const handleContainerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? null : e.target.value
handleChange('container', value)
}
// --------------------------------------------------------------
// Render
// --------------------------------------------------------------
return ( return (
<Paper sx={{ p: 3 }}> <Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
@@ -111,55 +177,101 @@ export const OptionsPanel: React.FC = () => {
</Alert> </Alert>
)} )}
{/* Output Settings */} {/* ------------------------------------------------------------------------
Output Settings
------------------------------------------------------------------------ */}
<Section title="Output Settings" defaultExpanded> <Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Container dropdown */}
<TextField <TextField
label="Format" label="Container"
select select
value={options.format} value={options.container ?? ''}
onChange={handleFormatChange} onChange={handleContainerChange}
fullWidth fullWidth
size="small" size="small"
error={!!formatError} error={!!formatError}
helperText={
formatError || "Select a container (auto-detect if empty)."
}
> >
<MenuItem value="mp3">MP3</MenuItem> <MenuItem value="">Auto-detect</MenuItem>
<MenuItem value="m4a">M4A</MenuItem> {containers.map((c) => (
<MenuItem value="mkv">MKV</MenuItem> <MenuItem key={c.name} value={c.name}>
<MenuItem value="mp4">MP4</MenuItem> {c.name.toUpperCase()} ({c.extension})
<MenuItem value="ogg">OGG</MenuItem> </MenuItem>
<MenuItem value="opus">OPUS</MenuItem> ))}
<MenuItem value="flac">FLAC</MenuItem>
<MenuItem value="wav">WAV</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
</TextField> </TextField>
{/* Audio Codec dropdown */}
<TextField <TextField
label="Transcode to" label="Audio Codec"
select select
value={options.transcode_to || ''} value={options.audio_codec}
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)} onChange={(e) => handleChange('audio_codec', e.target.value)}
fullWidth fullWidth
size="small" size="small"
helperText="Audio codec (copy = keep original)"
> >
<MenuItem value="">Copy (no transcoding)</MenuItem> <MenuItem value="copy">Copy (original)</MenuItem>
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem> {filteredAudioCodecs.map((c) => (
<MenuItem value="aac">AAC</MenuItem> <MenuItem key={c.name} value={c.name}>
<MenuItem value="libopus">OPUS</MenuItem> {c.name.toUpperCase()}
</MenuItem>
))}
</TextField> </TextField>
{/* Video Codec dropdown */}
<TextField
label="Video Codec"
select
value={options.video_codec}
onChange={(e) => handleChange('video_codec', e.target.value)}
fullWidth
size="small"
helperText="Video codec (copy = keep original)"
disabled={!hasVideo || options.drop_video}
>
<MenuItem value="copy">Copy (original)</MenuItem>
{filteredVideoCodecs.map((c) => (
<MenuItem key={c.name} value={c.name}>
{c.name.toUpperCase()}
</MenuItem>
))}
</TextField>
{/* Video Quality */}
<TextField
label="Video Quality"
type="number"
value={options.video_quality ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseInt(e.target.value, 10)
handleChange('video_quality', val)
}}
fullWidth
size="small"
disabled={!hasVideo || options.drop_video}
helperText="Optional quality value (encoder-specific; e.g., 1-31 for libx264, 0-10 for Theora)"
InputProps={{
inputProps: { min: 0, max: 51, step: 1 },
}}
/>
{/* Drop video (if video present) */}
{hasVideo && ( {hasVideo && (
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
checked={options.drop_video} checked={options.drop_video}
onChange={handleDropVideoChange} onChange={(e) => handleChange('drop_video', e.target.checked)}
/> />
} }
label="Drop video streams" label="Drop video streams"
/> />
)} )}
{/* Drop subtitles */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -172,7 +284,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Filename Settings */} {/* ------------------------------------------------------------------------
Filename Settings
------------------------------------------------------------------------ */}
<Section title="Filename Settings"> <Section title="Filename Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -228,7 +342,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Metadata Settings */} {/* ------------------------------------------------------------------------
Metadata Settings
------------------------------------------------------------------------ */}
<Section title="Metadata Settings"> <Section title="Metadata Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -259,7 +375,7 @@ export const OptionsPanel: React.FC = () => {
type="number" type="number"
value={options.comment_stream ?? ''} value={options.comment_stream ?? ''}
onChange={(e) => onChange={(e) =>
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value)) handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value, 10))
} }
size="small" size="small"
disabled={options.no_comment} disabled={options.no_comment}
@@ -284,7 +400,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Tracklist Settings */} {/* ------------------------------------------------------------------------
Tracklist Settings
------------------------------------------------------------------------ */}
<Section title="Tracklist Settings" defaultExpanded> <Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -299,9 +417,4 @@ export const OptionsPanel: React.FC = () => {
</Section> </Section>
</Paper> </Paper>
) )
// Helper function for option updates
function handleChange(field: string, value: any) {
setOptions({ [field]: value })
}
} }
+41 -12
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Box, Paper, TextField, Typography, Alert } from '@mui/material' import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore' import { useTracklistStore } from '../stores/tracklistStore'
@@ -10,6 +10,9 @@ export const TracklistEditor: React.FC = () => {
const { options } = useOptionsStore() const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const lineNumbersRef = useRef<HTMLDivElement>(null)
const validate = (text: string) => { const validate = (text: string) => {
const result = parseAndValidateTracklist(text, options.tracklist_format) const result = parseAndValidateTracklist(text, options.tracklist_format)
setEntries(result.entries) setEntries(result.entries)
@@ -23,10 +26,29 @@ export const TracklistEditor: React.FC = () => {
validate(text) validate(text)
} }
const handleScroll = useCallback(() => {
if (lineNumbersRef.current && textAreaRef.current) {
lineNumbersRef.current.scrollTop = textAreaRef.current.scrollTop
}
}, [])
useEffect(() => {
const textArea = textAreaRef.current
if (textArea) {
textArea.addEventListener('scroll', handleScroll)
return () => textArea.removeEventListener('scroll', handleScroll)
}
}, [handleScroll])
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [options.tracklist_format])
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[]) => { (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return if (acceptedFiles.length === 0) return
const file = acceptedFiles[0] const file = acceptedFiles[0]
const reader = new FileReader() const reader = new FileReader()
reader.onload = (event) => { reader.onload = (event) => {
@@ -48,13 +70,6 @@ export const TracklistEditor: React.FC = () => {
multiple: false, multiple: false,
}) })
// Re-validate when tracklist format changes
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [options.tracklist_format])
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
return ( return (
@@ -82,12 +97,13 @@ export const TracklistEditor: React.FC = () => {
</Typography> </Typography>
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 2 }}> <Box sx={{ display: 'flex', gap: 2, position: 'relative' }}>
{/* Line numbers column */} {/* Line numbers column */}
<Box <Box
ref={lineNumbersRef}
sx={{ sx={{
minWidth: 40, minWidth: 40,
maxWidth: 40, maxWidth: 60, // Allow more space for 3-digit numbers
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '14px', fontSize: '14px',
lineHeight: 1.7, lineHeight: 1.7,
@@ -95,6 +111,11 @@ export const TracklistEditor: React.FC = () => {
textAlign: 'right', textAlign: 'right',
userSelect: 'none', userSelect: 'none',
overflow: 'hidden', overflow: 'hidden',
paddingTop: '8.5px',
paddingBottom: '8.5px',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
whiteSpace: 'nowrap', // Prevent wrapping of line numbers
}} }}
> >
{rawText.split('\n').map((_, i) => ( {rawText.split('\n').map((_, i) => (
@@ -102,7 +123,7 @@ export const TracklistEditor: React.FC = () => {
))} ))}
</Box> </Box>
{/* Editor text area */} {/* Editor text area now with horizontal scroll and no wrap */}
<TextField <TextField
multiline multiline
fullWidth fullWidth
@@ -112,11 +133,19 @@ export const TracklistEditor: React.FC = () => {
onChange={handleTextChange} onChange={handleTextChange}
placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`} placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
variant="outlined" variant="outlined"
inputRef={textAreaRef}
sx={{ sx={{
'& .MuiInputBase-root': { '& .MuiInputBase-root': {
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: '14px', fontSize: '14px',
lineHeight: 1.7, lineHeight: 1.7,
overflowX: 'auto', // Enable horizontal scroll
},
'& .MuiInputBase-input': {
paddingTop: '8.5px',
paddingBottom: '8.5px',
whiteSpace: 'nowrap', // Prevent wrapping
overflowX: 'auto',
}, },
}} }}
error={!isValid && errors.length > 0} error={!isValid && errors.length > 0}
+15 -3
View File
@@ -3,7 +3,8 @@ import { useDropzone } from 'react-dropzone'
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material' import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
import { CloudUpload, InsertDriveFile } from '@mui/icons-material' import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { uploadFile, getTaskInfo } from '../api/client' import { useOptionsStore } from '../stores/optionsStore' // NEW
import { uploadFile, getTaskInfo, getRecommendedFormat } from '../api/client' // NEW
import { useTaskStore } from '../stores/taskStore' import { useTaskStore } from '../stores/taskStore'
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3'] const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
@@ -30,6 +31,7 @@ export const UploadZone: React.FC = () => {
} = useUploadStore() } = useUploadStore()
const { setTaskId: setTaskIdStore } = useTaskStore() const { setTaskId: setTaskIdStore } = useTaskStore()
const { setOptions } = useOptionsStore() // NEW
const onDrop = useCallback( const onDrop = useCallback(
async (acceptedFiles: File[]) => { async (acceptedFiles: File[]) => {
@@ -65,9 +67,19 @@ export const UploadZone: React.FC = () => {
setHasAudio(info.has_audio) setHasAudio(info.has_audio)
setHasSubtitle(info.has_subtitle) setHasSubtitle(info.has_subtitle)
setAudioCodec(info.audio_codec) setAudioCodec(info.audio_codec)
// Fetch recommended format and update options
try {
// Inside UploadZone.tsx, after fetching recommended format:
const rec = await getRecommendedFormat(taskId)
// Update options store with container (not format)
setOptions({ container: rec.format })
} catch (err) {
console.warn('Failed to fetch recommended format, using default', err)
}
} catch (err) { } catch (err) {
console.error('Failed to fetch stream info:', err) console.error('Failed to fetch stream info:', err)
// Don't block the upload flow if this fails; we'll just assume no video // Don't block upload flow; user can manually change options
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Upload failed') setError(err.response?.data?.detail || err.message || 'Upload failed')
@@ -78,7 +90,7 @@ export const UploadZone: React.FC = () => {
setFileSize(0) setFileSize(0)
} }
}, },
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore] [setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore, setHasVideo, setHasAudio, setHasSubtitle, setAudioCodec, setOptions]
) )
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+60 -20
View File
@@ -1,37 +1,77 @@
import { create } from 'zustand' import { create } from 'zustand'
import { SplitOptions } from '../types' import { SplitOptions, ContainerInfo, CodecInfo } from '../types'
import {
DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE,
DEFAULT_REPLACEMENT_CHAR,
DEFAULT_BAD_CHARS,
DEFAULT_ALBUM,
DEFAULT_COMMENT,
DEFAULT_NO_COMMENT,
DEFAULT_COMMENT_STREAM,
DEFAULT_MERGE_COMMENTS,
DEFAULT_COMMENT_SEPARATOR,
DEFAULT_DROP_VIDEO,
DEFAULT_DROP_SUBS,
DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING,
DEFAULT_TRACKLIST_FORMAT,
} from '../constants/generated'
// We use DEFAULT_FORMAT only as a fallback; container default is null (auto-detect)
const DEFAULT_OPTIONS: SplitOptions = { const DEFAULT_OPTIONS: SplitOptions = {
format: 'mp3', container: null,
transcode_to: '', audio_codec: 'copy',
drop_video: false, video_codec: 'copy',
drop_subs: false, subtitle_codec: 'copy',
number_tracks: false, video_quality: null,
replace_bad_chars: false, drop_video: DEFAULT_DROP_VIDEO,
replacement_char: '_', drop_subs: DEFAULT_DROP_SUBS,
bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ', number_tracks: DEFAULT_NUMBER_TRACKS,
skip_existing: false, replace_bad_chars: DEFAULT_REPLACE_BAD_CHARS,
output_template: '%an-%tn.%ext', replacement_char: DEFAULT_REPLACEMENT_CHAR,
album: '', bad_chars: DEFAULT_BAD_CHARS,
comment: '', skip_existing: DEFAULT_SKIP_EXISTING,
no_comment: false, output_template: DEFAULT_OUTPUT_TEMPLATE,
comment_stream: null, album: DEFAULT_ALBUM ?? '',
merge_comments: false, comment: DEFAULT_COMMENT ?? '',
comment_separator: '; ', no_comment: DEFAULT_NO_COMMENT,
tracklist_format: '%ts %tn - %an', // NEW comment_stream: DEFAULT_COMMENT_STREAM,
merge_comments: DEFAULT_MERGE_COMMENTS,
comment_separator: DEFAULT_COMMENT_SEPARATOR,
tracklist_format: DEFAULT_TRACKLIST_FORMAT,
} }
interface OptionsState { interface OptionsState {
options: SplitOptions options: SplitOptions
setOptions: (options: Partial<SplitOptions>) => void containers: ContainerInfo[]
codecs: CodecInfo[]
compatibility: Record<string, { audio: string[] | null; video: string[] | null }>
setOptions: (newOptions: Partial<SplitOptions>) => void
setContainers: (containers: ContainerInfo[]) => void
setCodecs: (codecs: CodecInfo[]) => void
setCompatibility: (compat: Record<string, { audio: string[] | null; video: string[] | null }>) => void
reset: () => void reset: () => void
} }
export const useOptionsStore = create<OptionsState>((set) => ({ export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS }, options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
setOptions: (newOptions) => setOptions: (newOptions) =>
set((state) => ({ set((state) => ({
options: { ...state.options, ...newOptions }, options: { ...state.options, ...newOptions },
})), })),
reset: () => set({ options: { ...DEFAULT_OPTIONS } }), setContainers: (containers) => set({ containers }),
setCodecs: (codecs) => set({ codecs }),
setCompatibility: (compatibility) => set({ compatibility }),
reset: () =>
set({
options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
}),
})) }))
+35 -3
View File
@@ -22,8 +22,11 @@ export interface TaskStatus {
} }
export interface SplitOptions { export interface SplitOptions {
format: string container: string | null // null = auto-detect
transcode_to?: string audio_codec: string // 'copy' or encoder name
video_codec: string // 'copy' or encoder name
subtitle_codec: string // 'copy' or encoder name
video_quality: number | null // encoder-specific integer
drop_video: boolean drop_video: boolean
drop_subs: boolean drop_subs: boolean
number_tracks: boolean number_tracks: boolean
@@ -38,7 +41,36 @@ export interface SplitOptions {
comment_stream: number | null comment_stream: number | null
merge_comments: boolean merge_comments: boolean
comment_separator: string comment_separator: string
tracklist_format: string tracklist_format: string // for frontend parsing
}
export interface ContainerInfo {
name: string
ffmpeg: string
extension: string
audio_only: boolean
supports_video: boolean
supports_subs: boolean
}
export interface CodecInfo {
name: string
ffmpeg: string
recommended_container: string
recommended_extension: string
supports_transcoding: boolean
supports_video: boolean
}
export interface CompatibilityEntry {
audio: string[] | null
video: string[] | null
}
export interface FormatsResponse {
containers: ContainerInfo[]
codecs: CodecInfo[]
compatibility: Record<string, CompatibilityEntry>
} }
export interface UploadResponse { export interface UploadResponse {