42 Commits

Author SHA1 Message Date
max 2ab85b64ff fix #26 (backend,frontend): Frontend can't apply codecs filters correctly 2026-09-02 21:24:18 +05:00
max cf0b9f62cb fix #22 (backend,frontend): the backend and frontend had whitelists that only included audio formats 2026-09-02 20:12:00 +05:00
max 1b0767af90 fix #27 (core): no write permissions fail 2026-09-02 19:36:28 +05:00
max a8b0c538ff fix #25 (core): wrong output filename extensions when they are autodetected 2026-09-02 19:14:57 +05:00
max 3a41bd2920 fix #18 (core): malformed names of output files 2026-09-02 18:30:53 +05:00
max 78ec4dd63a Fix #28: warn when timestamps overlap
Add overlap detection in resolve_end_times. When consecutive tracks have
overlapping time ranges (end of track N > start of track N+1), a warning
is printed but processing continues as before.
2026-09-02 16:57:10 +05:00
max 8b67de59a3 Fix #29: validate --container against known containers
Before this fix, passing an invalid container name (e.g., 'nonexistent')
was passed directly to FFmpeg, which would fail with a cryptic error.
Now the container name is validated against CONTAINER_NAMES before any
processing begins, and a clear error message listing valid containers
is shown.
2026-09-02 15:41:38 +05:00
max 25e8c0293e feat (core): allows user to add an external cover image to split tracks 2026-09-02 11:42:47 +05:00
max e36c1d69ed minor (core): updates DEFAULT_BAD_CHARS 2026-09-01 15:54:38 +05:00
max e77434cf30 feat (core): updates codecs info in constants.py 2026-09-01 15:47:02 +05:00
max 90d73d0e74 refact (core): removes useless fields from CODEC_INFO 2026-09-01 13:08:28 +05:00
max 7a7b63b03b refact (core): simplifies constants.py, removes redundancy, updates dependent code 2026-09-01 12:17:21 +05:00
max a67acc31b7 feat (back): removes cover image handling, since it's in core now 2026-08-31 19:50:44 +05:00
max 5f68eccdbf feat (core): If user_format is provided, map it to the correct container if it's a codec name 2026-08-31 19:48:31 +05:00
max 156dfa693f update (core): new values for codec/container/file_extension compatibility 2026-08-31 14:20:02 +05:00
max 580837fa73 feature (core): adds special handlers for audio+cover_image cases 2026-08-31 14:19:07 +05:00
max 747a349877 fix (core): checks the compatibility even if default options are used 2026-08-30 19:00:12 +05:00
max 6f6637a9a2 REFACTOR (BACKEND): removes an useless file 2026-08-30 15:48:28 +05:00
max 5e02184c8f REFACTOR (FRONTEND): exports some functionality from web/frontend/src/components/OptionsPanel.tsx into separated hooks 2026-08-30 15:26:02 +05:00
max e935241fd8 REFACTOR (CORE): splits build_ffmpeg_command method into one orchestrator and helper methods 2026-08-30 14:47:13 +05:00
max 8e0d345ba7 FIX: maps codecs names and ffmpeg encoders correctly 2026-08-28 15:41:21 +05:00
max 3533525df0 FEATURE: propagate improvements from CLI and web-API versions to web-frontend 2026-08-28 15:40:38 +05:00
max 834239674a FEATURE: propogate improvements from CLI version to web-backend 2026-08-28 13:24:46 +05:00
max 748532be7c FIX: codec/container/file_extension autodetection when default values are used 2026-08-28 11:04:40 +05:00
max f3abbb8f0c FEATURE: adds quality flag for video stream 2026-08-25 17:00:43 +05:00
max 690c5f98c6 FEATURE: adds strict codec/container/file_extension verification, renames ambiguous options, adds flexible transcoding 2026-08-25 10:40:48 +05:00
max 262d458ce4 FEATURE: codecs/containers/file_extensions inconsistency fixed 2026-08-21 15:59:05 +05:00
max 7af2600ed8 FEATURE: all versions use single source of truth for codec/container/file_extension info 2026-08-21 14:58:55 +05:00
max d08045bf02 Merge pull request 'fix(frontend): frontend functional bugs' (#9) from frontend_bugs into dev
Reviewed-on: #9
2026-08-21 13:35:17 +04:00
max 4843ac1cb5 FEATURE: clarifes 'format' option in output section of the frontend 2026-08-20 20:21:15 +05:00
max 364fc7a8fe FEATURE: adds automatic format detected to the frontend 2026-08-20 19:59:11 +05:00
max 8a1e2a5335 FIX: aligns line number with the corresponding lines in the tracklist editor 2026-08-20 12:47:58 +05:00
max 3c7ff97e76 Merge pull request 'feat(core): single source of truth for default values added' (#6) from default_args into dev
Reviewed-on: #6
2026-08-20 09:34:21 +04:00
max 140a0fbc46 FEATURE: single source of truth for default values added 2026-08-20 10:28:07 +05:00
max 05fcc8ea29 Merge pull request 'FEAT(backend): allows the backend to take a tracklis text file' (#5) from backend_tracks_file into dev
Reviewed-on: #5
2026-08-19 16:30:10 +04:00
max 52d01f1bcd FEATURE: allows the backend to take a tracklis text file 2026-08-19 17:26:07 +05:00
max 607836b280 CHANGE: drops cli version docker support 2026-08-19 13:12:34 +05:00
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
39 changed files with 2022 additions and 879 deletions
+1
View File
@@ -1,4 +1,5 @@
name: Build and Push Docker Image
run-name: Production images are being built
on:
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
- **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
+121 -17
View File
@@ -1,20 +1,124 @@
"""Global constants and default values."""
# audio_splitter/constants.py
"""Global constants for the audio splitter.
# Mapping from userfriendly format names to FFmpeg format identifiers,
# file extensions, and whether the container is audioonly.
FORMAT_INFO = {
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
This file serves as the single source of truth for:
- Container formats and their properties
- Audio codecs and their recommended containers
- Video codec support per container
- File extensions for each codec/container combination
- Compatibility matrix for codec/container validation
Codec != Container != File Extension.
Example: Opus (codec) → Ogg (container) → .opus (extension)
"""
from typing import Dict, Optional
# ------------------------------------------------------------------------------
# Container information
# ------------------------------------------------------------------------------
CONTAINER_INFO = [
# Audio-only containers
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'supports_video': True, 'supports_subs': False},
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'supports_video': False, 'supports_subs': False},
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'supports_video': False, 'supports_subs': False},
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'supports_video': False, 'supports_subs': False},
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'supports_video': False, 'supports_subs': False},
# Containers that support video and subtitles
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'supports_video': True, 'supports_subs': True},
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'supports_video': True, 'supports_subs': True},
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'supports_video': True, 'supports_subs': True},
{'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'supports_video': True, 'supports_subs': False},
]
# Container name lookup set for fast membership checks
CONTAINER_NAMES = {container['name'] for container in CONTAINER_INFO}
def get_container_info(container_name: str) -> Optional[Dict]:
"""Return container info dict or None if not found."""
for container in CONTAINER_INFO:
if container['name'] == container_name:
return container
return None
# ------------------------------------------------------------------------------
# Codec information
# ------------------------------------------------------------------------------
CODEC_INFO = [
# Audio codecs
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'codec_type': 'audio'},
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'codec_type': 'audio'},
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'codec_type': 'audio'},
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'codec_type': 'audio'},
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'codec_type': 'audio'},
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'codec_type': 'audio'},
{'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'codec_type': 'audio'},
# Video codecs (including cover images)
{'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'codec_type': 'video'},
{'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'codec_type': 'video'},
{'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'codec_type': 'video'},
{'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'codec_type': 'video'},
{'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'codec_type': 'video'},
{'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'codec_type': 'video'},
{'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'codec_type': 'video'},
{'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'codec_type': 'video'},
# Subtitle codecs
{'name': 'srt', 'ffmpeg': 'srt', 'recommended_container': 'mkv', 'recommended_extension': '.srt', 'codec_type': 'subtitle'},
{'name': 'ass', 'ffmpeg': 'ass', 'recommended_container': 'mkv', 'recommended_extension': '.ass', 'codec_type': 'subtitle'},
{'name': 'vtt', 'ffmpeg': 'webvtt', 'recommended_container': 'webm', 'recommended_extension': '.vtt', 'codec_type': 'subtitle'},
]
# Map codec name → recommended container
CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO}
CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO}
# Map codec name → FFmpeg encoder name
CODEC_NAME_TO_FFMPEG = {codec['name']: codec['ffmpeg'] for codec in CODEC_INFO}
# ------------------------------------------------------------------------------
# Compatibility matrix: container → supported audio and video codecs
# ------------------------------------------------------------------------------
# Each container entry maps to a dict with 'audio' and 'video' keys.
# - 'audio': list of audio codec names that are supported in this container.
# Use None to indicate that any audio codec is supported.
# - 'video': list of video codec names that are supported in this container.
# Use None to indicate that any video codec is supported.
# Empty list means no video support (audio-only container).
COMPATIBILITY_MATRIX = {
'mp3': {'audio': ['mp3'], 'video': ['png', 'mjpeg']},
'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []},
'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']},
'mkv': {'audio': None, 'video': None},
'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8', 'png', 'mjpeg']},
'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']},
'flac': {'audio': ['flac'], 'video': ['png', 'mjpeg']},
'wav': {'audio': ['pcm_s16le'], 'video': []},
'aac': {'audio': ['aac'], 'video': []},
}
# Default characters to replace when --replace-bad-chars is enabled.
# Includes common punctuation, single quote, and a trailing space.
DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' '
# ------------------------------------------------------------------------------
# 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'!@#№$;:%^&?*(){}[]\/<>+=~`\'|," '
+204 -29
View File
@@ -1,36 +1,87 @@
"""Core logic: orchestrates the splitting process."""
# audio_splitter/core.py
"""Core splitting logic orchestrates the entire split process."""
import os
import subprocess
import sys
from typing import List, Dict, Any
from .constants import FORMAT_INFO
from .ffmpeg import get_audio_duration, get_stream_info, get_metadata, build_ffmpeg_command
from .formats import determine_output_format, validate_format_compatibility
from .constants import (
CONTAINER_INFO,
CONTAINER_NAMES,
CODEC_TO_EXTENSION_MAP,
CODEC_NAME_TO_FFMPEG,
)
from .ffmpeg import (
get_audio_duration,
get_stream_info,
get_metadata,
build_ffmpeg_command,
is_attached_picture,
extract_cover_image,
)
from .formats import (
determine_output_format,
validate_format_compatibility,
)
from .handlers import get_handler, needs_drop_video
from .timestamp import parse_track_timestamps, resolve_end_times
from .filename import build_filename
from .metadata import build_metadata_dict
from .utils import format_time
def split_audio(input_file, output_directory, tracks, args):
def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, Any]], args) -> None:
"""
Main orchestration function: split the audio file into tracks.
Args:
input_file: Path to the input media file.
output_directory: Directory where output files will be saved.
tracks: List of dicts, each containing parsed fields.
args: Parsed commandline arguments (namespace).
tracks: List of dicts, each containing parsed fields (ts, tn, an, ...).
args: Parsed commandline arguments (namespace) with attributes:
- container: output container name (or None for auto-detect)
- audio_codec: audio codec (copy or encoder)
- video_codec: video codec (copy or encoder)
- subtitle_codec: subtitle codec (copy or encoder)
- video_quality: integer or None
- drop_video: bool
- drop_subs: bool
- number_tracks: bool
- output_template: str
- replace_bad_chars: bool
- replacement_char: str
- bad_chars: str
- skip_existing: bool
- album: str or None
- comment: str or None
- no_comment: bool
- comment_stream: int or None
- merge_comments: bool
- comment_separator: str
- delete_original: bool
- cover_image: List[str] or None (optional, CLI list of cover image paths)
Raises:
RuntimeError: If no audio stream is found.
ValueError: If timestamp parsing or format compatibility fails.
ValueError: If compatibility validation fails.
"""
# --------------------------------------------------------------------------
# 1. Setup
# --------------------------------------------------------------------------
os.makedirs(output_directory, exist_ok=True)
# Check write permission on output directory.
try:
test_file = os.path.join(output_directory, f'.write_test_{os.getpid()}')
with open(test_file, 'w') as f:
f.write('test')
os.remove(test_file)
except PermissionError:
raise RuntimeError(
f"Error: No write permission for output directory: {output_directory}"
)
total_duration = get_audio_duration(input_file)
stream_info = get_stream_info(input_file)
@@ -42,26 +93,62 @@ def split_audio(input_file, output_directory, tracks, args):
raise RuntimeError("No audio stream found in input file.")
# --------------------------------------------------------------------------
# 2. Format decision
# 2. Determine output container
# --------------------------------------------------------------------------
output_format = determine_output_format(stream_info, args.format, args.transcode_to)
print(f"Output container: {output_format}")
validate_format_compatibility(output_format, stream_info,
args.drop_video, args.drop_subs)
# Determine file extension.
extension_info = FORMAT_INFO.get(output_format, {})
extension = extension_info.get('ext', '.mkv')
user_container = getattr(args, 'container', None)
output_container = determine_output_format(
stream_info,
user_format=user_container,
transcode_audio=args.audio_codec if args.audio_codec != 'copy' else None,
input_file=input_file
)
print(f"Output container: {output_container}")
# --------------------------------------------------------------------------
# 3. Parse timestamps
# 3. Validate compatibility
# --------------------------------------------------------------------------
try:
validate_format_compatibility(
container=output_container,
stream_info=stream_info,
drop_video=args.drop_video,
drop_subs=args.drop_subs,
input_file=input_file,
audio_codec=args.audio_codec,
video_codec=args.video_codec,
)
except ValueError as e:
raise RuntimeError(f"Compatibility error: {e}")
# --------------------------------------------------------------------------
# 4. Determine output audio codec and extension
# --------------------------------------------------------------------------
# Determine the audio codec that will be used in the output
if args.audio_codec != 'copy':
output_audio_codec = args.audio_codec
else:
output_audio_codec = stream_info.get('audio_codec', '')
# Choose extension based on the output container, not the audio codec.
# When user specifies --container, the file extension must match the container.
container_info = next((c for c in CONTAINER_INFO if c['name'] == output_container), None)
if container_info:
extension = container_info['extension']
elif output_audio_codec in CODEC_TO_EXTENSION_MAP:
extension = CODEC_TO_EXTENSION_MAP[output_audio_codec]
else:
extension = '.mkv'
print(f"Output extension: {extension}")
# --------------------------------------------------------------------------
# 5. Parse timestamps
# --------------------------------------------------------------------------
track_times = parse_track_timestamps(tracks)
resolved_times = resolve_end_times(track_times, total_duration)
# --------------------------------------------------------------------------
# 4. Fetch original metadata (for fallbacks)
# 6. Fetch original metadata (for fallbacks)
# --------------------------------------------------------------------------
input_metadata = get_metadata(input_file)
original_album = input_metadata.get('album')
@@ -78,7 +165,39 @@ def split_audio(input_file, output_directory, tracks, args):
print(f" Stream {idx}: '{comment}'")
# --------------------------------------------------------------------------
# 5. Process each track
# 7. Handle attached picture (cover art)
# --------------------------------------------------------------------------
user_cover_images = getattr(args, 'cover_image', None)
# Resolve per-track cover images: single image reused, or one-per-track
track_cover_images = []
if user_cover_images:
for img in user_cover_images:
if os.path.exists(img):
track_cover_images.append(img)
else:
print(f"Warning: Cover image not found, skipping: {img}")
# Extract cover from input if no user cover and input has video
if not track_cover_images and not args.drop_video and stream_info.get('has_video'):
if is_attached_picture(input_file):
cover_image_path = os.path.join(output_directory, 'cover.png')
if extract_cover_image(input_file, cover_image_path):
track_cover_images.append(cover_image_path)
print("Extracted cover image for all tracks.")
else:
track_cover_images = []
# Check if we need special handling (e.g., Opus files need opustags)
# Use output_audio_codec (not input_audio_codec) so the handler is resolved
# against the actual output codec (e.g., transcoding aac→opus in ogg).
input_audio_codec = stream_info.get('audio_codec')
cover_handler = get_handler(output_container, output_audio_codec)
needs_drop = needs_drop_video(output_container, output_audio_codec)
if track_cover_images and needs_drop:
print(f"Using special handler for {output_container} + {output_audio_codec}")
print("Temporarily dropping video for opustags post-processing.")
# --------------------------------------------------------------------------
# 8. Process each track
# --------------------------------------------------------------------------
for idx, track in enumerate(tracks, start=1):
start_seconds, end_seconds = resolved_times[idx - 1]
@@ -91,31 +210,62 @@ def split_audio(input_file, output_directory, tracks, args):
track_name = track.get('tn', 'Unknown')
# ----------------------------------------------------------------------
# 5a. Build filename
# 8a. Build filename
# ----------------------------------------------------------------------
clean_filename = build_filename(track, idx, extension, args)
output_path = os.path.join(output_directory, clean_filename)
# ----------------------------------------------------------------------
# 5b. Handle existing files
# 8b. Handle existing files
# ----------------------------------------------------------------------
if args.skip_existing and os.path.exists(output_path):
print(f"Skipping track {idx}: {output_path} already exists.")
continue
# ----------------------------------------------------------------------
# 5c. Build metadata
# 8c. Build metadata
# ----------------------------------------------------------------------
metadata = build_metadata_dict(track, idx, input_metadata, args)
# ----------------------------------------------------------------------
# 5d. Build and execute FFmpeg command
# 8d. Build and execute FFmpeg command
# ----------------------------------------------------------------------
# Map codec names to FFmpeg encoder names
audio_enc = args.audio_codec
if audio_enc != 'copy':
audio_enc = CODEC_NAME_TO_FFMPEG.get(audio_enc, audio_enc) # fallback to itself if not found
video_enc = args.video_codec
if video_enc != 'copy':
video_enc = CODEC_NAME_TO_FFMPEG.get(video_enc, video_enc)
subtitle_enc = args.subtitle_codec
if subtitle_enc != 'copy':
subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc)
# Then build command with these mapped encoders
# Use temporary drop_video for handlers that need it
effective_drop_video = args.drop_video or (track_cover_images and needs_drop)
# For handlers that need opustags post-processing, don't pass cover_image_path
# to ffmpeg - it will process audio-only, then handler adds cover afterward
per_track_cover = (
track_cover_images[idx - 1] if len(track_cover_images) == len(tracks)
else (track_cover_images[0] if track_cover_images else None)
)
ffmpeg_cover_path = None if (per_track_cover and needs_drop) else per_track_cover
cmd = build_ffmpeg_command(
input_file, start_seconds, duration_seconds, output_path,
stream_info, output_format, args.transcode_to,
args.drop_video, args.drop_subs,
metadata=metadata
input_file=input_file,
start_seconds=start_seconds,
duration_seconds=duration_seconds,
output_path=output_path,
stream_info=stream_info,
format_opt=output_container,
audio_codec=audio_enc,
video_codec=video_enc,
subtitle_codec=subtitle_enc,
metadata=metadata,
cover_image_path=ffmpeg_cover_path,
video_quality=getattr(args, 'video_quality', None),
drop_video=effective_drop_video,
drop_subs=args.drop_subs,
)
print(f"Extracting track {idx}: {track_name} "
@@ -128,3 +278,28 @@ def split_audio(input_file, output_directory, tracks, args):
print(result.stderr)
else:
print(f" -> Saved to: {output_path}")
# Apply cover image handler if needed
if per_track_cover and needs_drop:
print(f"Applying cover image via {output_container} handler...")
if not cover_handler(
input_file=input_file,
cover_image_path=per_track_cover,
output_path=output_path,
stream_info=stream_info,
audio_codec=audio_enc,
video_codec=video_enc,
video_quality=getattr(args, 'video_quality', None),
drop_video=args.drop_video,
drop_subs=args.drop_subs,
):
print(f"Warning: Failed to attach cover image to {output_path}")
# --------------------------------------------------------------------------
# 9. Delete original file if requested
# --------------------------------------------------------------------------
if getattr(args, 'delete_original', False):
try:
os.remove(input_file)
print(f"Deleted original file: {input_file}")
except OSError as e:
print(f"Warning: Could not delete original file: {e}")
+35
View File
@@ -0,0 +1,35 @@
"""Default values for all configurable options shared between CLI, web backend, and frontend."""
# Output container format (used when user doesn't specify one; auto-detection overrides this)
DEFAULT_FORMAT = "mp3"
# Filename template
DEFAULT_OUTPUT_TEMPLATE = "%an-%tn.%ext"
# Character replacement
DEFAULT_REPLACEMENT_CHAR = "_"
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
# Tracklist parsing
DEFAULT_TRACKLIST_FORMAT = "%ts %tn - %an"
# Metadata defaults
DEFAULT_ALBUM = None
DEFAULT_COMMENT = None
DEFAULT_COMMENT_STREAM = None
DEFAULT_COMMENT_SEPARATOR = "; "
DEFAULT_NO_COMMENT = False
DEFAULT_MERGE_COMMENTS = False
# Stream handling
DEFAULT_DROP_VIDEO = False
DEFAULT_DROP_SUBS = False
# Filename/export options
DEFAULT_NUMBER_TRACKS = False
DEFAULT_REPLACE_BAD_CHARS = True
DEFAULT_SKIP_EXISTING = False
DEFAULT_DELETE_ORIGINAL = False
# Transcoding (None means copy codec)
DEFAULT_TRANSCODE_TO = None
+395 -102
View File
@@ -1,9 +1,12 @@
"""FFmpeg / FFprobe interactions and command building."""
# audio_splitter/ffmpeg.py
"""FFmpeg/FFprobe interaction utilities."""
import subprocess
import json
import os
import subprocess
from typing import Dict, List, Optional, Tuple
from .constants import FORMAT_INFO
from .constants import CONTAINER_INFO
from .utils import format_time
@@ -24,7 +27,10 @@ def get_audio_duration(input_file: str) -> float:
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
try:
return float(result.stdout.strip())
except ValueError:
return 0.0
def has_stream_type(input_file: str, stream_type: str) -> bool:
@@ -49,7 +55,7 @@ def has_stream_type(input_file: str, stream_type: str) -> bool:
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.
@@ -71,7 +77,29 @@ def get_audio_codec(input_file: str) -> str:
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.
@@ -88,94 +116,12 @@ def get_stream_info(input_file: str):
'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:
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:
def get_metadata(input_file: str) -> Dict[str, any]:
"""
Retrieve metadata from the input file using ffprobe with JSON output.
Returns a dict with:
- album: merged from all sources (last wins)
- title: merged from all sources (last wins)
- comments: list of (stream_index, comment) tuples
Returns a dict with keys: album, title, comments (list of (stream_index, comment)).
"""
cmd = [
'ffprobe', '-v', 'quiet',
@@ -190,34 +136,381 @@ def get_metadata(input_file: str) -> dict:
try:
data = json.loads(result.stdout)
# Collect album and title (merged, last wins).
album = None
title = None
comments = [] # list of (stream_index, comment)
comments = []
# Format tags.
fmt_tags = data.get('format', {}).get('tags', {})
album = fmt_tags.get('album') or album
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', [])):
stream_tags = stream.get('tags', {})
# Album and title: update if present.
if 'album' in stream_tags:
album = stream_tags['album']
if 'title' in stream_tags:
title = stream_tags['title']
# Comment: collect all occurrences.
if 'comment' in stream_tags:
comments.append((idx, stream_tags['comment']))
return {
'album': album,
'title': title,
'comments': comments
}
return {'album': album, 'title': title, 'comments': comments}
except (json.JSONDecodeError, KeyError):
return {'album': None, 'title': None, 'comments': []}
def get_container_format(input_file: str) -> Optional[str]:
"""
Retrieve the container format name (e.g., 'mp4', 'mp3', 'mkv') 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 CONTAINER_INFO
mapping = {
'mpeg': 'mp3',
'mp2': 'mp3',
'mp4': 'mp4',
'm4a': 'mp4',
'mov': 'mp4',
'3gp': 'mp4',
'matroska': 'mkv',
'webm': 'mkv',
'ogg': 'ogg',
'flac': 'flac',
'wav': 'wav',
'aac': 'aac',
'opus': 'opus',
'mp3': 'mp3',
'adts': 'aac',
'amr': 'amr',
}
return mapping.get(format_name, format_name)
MAX_COVER_IMAGE_SIZE_MB = 4
def has_cover_or_video(input_file: str) -> bool:
"""
Check if the input file contains a cover image (attached picture) or any video track.
Returns:
True if a cover image or video stream is detected, False otherwise.
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-select_streams', 'v',
'-show_entries', 'stream=codec_type,codec_name,disposition',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return False
try:
data = json.loads(result.stdout)
streams = data.get('streams', [])
return len(streams) > 0
except (json.JSONDecodeError, KeyError):
return False
def validate_cover_images(cover_images: List[str], num_tracks: int) -> None:
"""
Validate cover image(s) before splitting.
Checks:
- Each file exists
- Only PNG and JPEG formats are accepted
- Single image OR count matches number of tracks
- File size warning for images > 4 MB
Args:
cover_images: List of cover image paths (single image or multiple).
num_tracks: Number of tracks in the tracklist.
Raises:
ValueError: If validation fails.
"""
if len(cover_images) == 0:
return
if len(cover_images) == 1 and num_tracks > 1:
# Single image is fine - will be reused for all tracks
pass
elif len(cover_images) == num_tracks:
pass
else:
raise ValueError(
f"Cover image count ({len(cover_images)}) must be 1 or match "
f"the number of tracks ({num_tracks})."
)
for img_path in cover_images:
if not os.path.exists(img_path):
raise ValueError(f"Cover image not found: {img_path}")
ext = os.path.splitext(img_path)[1].lower()
if ext not in ('.png', '.jpg', '.jpeg'):
raise ValueError(
f"Unsupported cover image format '{ext}' for '{img_path}'. "
f"Only PNG and JPEG are supported."
)
size_mb = os.path.getsize(img_path) / (1024 * 1024)
if size_mb > MAX_COVER_IMAGE_SIZE_MB:
print(
f"Warning: Cover image '{img_path}' is {size_mb:.1f} MB "
f"(exceeds {MAX_COVER_IMAGE_SIZE_MB} MB limit). "
f"Large images may cause issues."
)
def is_attached_picture(input_file: str) -> bool:
"""
Check if the input file has a video stream that is an attached picture (cover art).
Detection logic:
1. If there is a video stream with disposition.attached_pic == 1, return True.
2. Otherwise, if there is exactly one video stream and its codec is an image
format (PNG, MJPEG, JPEG, GIF, BMP), return True.
Returns:
True if a cover image is detected, False otherwise.
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-select_streams', 'v',
'-show_entries', 'stream=codec_name,disposition',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return False
try:
data = json.loads(result.stdout)
streams = data.get('streams', [])
if not streams:
return False
# Check each stream
for stream in streams:
codec = stream.get('codec_name', '').lower()
disposition = stream.get('disposition', {})
# If attached_pic is set, it's a cover image
if disposition.get('attached_pic') == 1:
return True
# If not, check if it's an image codec and we have exactly one video stream
if codec in ('png', 'mjpeg', 'jpeg', 'gif', 'bmp'):
# If there is exactly one video stream, treat it as cover
if len(streams) == 1:
return True
return False
except (json.JSONDecodeError, KeyError):
return False
def extract_cover_image(input_file: str, output_path: str) -> bool:
"""
Extract the first frame of the video stream (assumed to be an attached picture)
and save it to output_path.
Returns:
True if extraction succeeded, False otherwise.
"""
cmd = [
'ffmpeg', '-i', input_file,
'-map', '0:v:0',
'-frames:v', '1',
'-y',
output_path
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"Failed to extract cover image: {result.stderr}")
return False
return True
def _build_inputs(cmd: List[str], input_file: str, cover_image_path: Optional[str]) -> None:
"""Add input files to the command."""
if cover_image_path:
cmd.extend(['-i', cover_image_path])
cmd.extend(['-i', input_file])
def _build_time_options(cmd: List[str], start_seconds: int, duration_seconds: int) -> None:
"""Add time-based options to the command."""
cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)])
def _build_metadata(cmd: List[str], metadata: Optional[Dict]) -> None:
"""Add metadata options to the command."""
cmd.append('-map_metadata')
cmd.append('-1')
if metadata:
for key, value in metadata.items():
if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"])
def _build_cover_image_mapping(
cmd: List[str],
audio_codec: Optional[str],
video_codec: Optional[str],
video_quality: Optional[int],
) -> None:
"""Build stream mapping for cover image extraction (audio + cover video)."""
cmd.extend(['-map', '1:a:0', '-map', '0:v:0'])
if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', video_codec])
else:
cmd.extend(['-c:v', 'png'])
if audio_codec and audio_codec != 'copy':
cmd.extend(['-c:a', audio_codec])
else:
cmd.extend(['-c:a', 'copy'])
# Preserve attached_pic disposition for cover image
cmd.extend(['-disposition', 'attached_pic'])
cmd.append('-sn')
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
def _build_standard_mapping(
cmd: List[str],
stream_info: Dict,
audio_codec: Optional[str],
video_codec: Optional[str],
subtitle_codec: Optional[str],
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> None:
"""Build stream mapping for standard extraction (no cover image)."""
if drop_video and drop_subs:
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'])
if audio_codec and audio_codec != 'copy':
cmd.extend(['-c:a', audio_codec])
else:
cmd.extend(['-c:a', 'copy'])
if not drop_video and stream_info.get('has_video'):
if video_codec and video_codec != 'copy':
cmd.extend(['-c:v', video_codec])
if video_quality is not None:
cmd.extend(['-q:v', str(video_quality)])
else:
cmd.extend(['-c:v', 'copy'])
else:
cmd.append('-vn')
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')
def _build_format(cmd: List[str], format_opt: Optional[str]) -> None:
"""Add output format option if specified."""
if format_opt:
ffmpeg_format = next((c['ffmpeg'] for c in CONTAINER_INFO if c['name'] == format_opt), format_opt)
cmd.extend(['-f', ffmpeg_format])
def _build_output(cmd: List[str], output_path: str) -> None:
"""Add output file to the command."""
cmd.extend(['-y', output_path])
def build_ffmpeg_command(
input_file: str,
start_seconds: int,
duration_seconds: int,
output_path: str,
stream_info: Dict,
format_opt: Optional[str],
audio_codec: Optional[str] = 'copy',
video_codec: Optional[str] = 'copy',
subtitle_codec: Optional[str] = 'copy',
metadata: Optional[Dict] = None,
cover_image_path: Optional[str] = None,
video_quality: Optional[int] = None,
drop_video: bool = False,
drop_subs: bool = False,
) -> List[str]:
"""
Construct the FFmpeg command line as a list of arguments.
Args:
input_file: Path to the input media file.
start_seconds: Start time for the segment (in seconds).
duration_seconds: Duration of the segment (in seconds).
output_path: Destination path for the output file.
stream_info: Dictionary from get_stream_info().
format_opt: Output container format (e.g., 'mp3', 'mkv').
audio_codec: Audio codec to use ('copy' or encoder name like 'libopus').
video_codec: Video codec to use ('copy' or encoder name).
subtitle_codec: Subtitle codec to use ('copy' or encoder name).
metadata: Optional dict of metadata key/value pairs to write.
cover_image_path: Path to extracted cover image (if any).
video_quality: Quality value for video encoder (e.g., 1-31, lower=better).
drop_video: If True, remove video streams.
drop_subs: If True, remove subtitle streams.
Returns:
A list of commandline arguments suitable for subprocess.run().
"""
cmd = ['ffmpeg']
_build_inputs(cmd, input_file, cover_image_path)
_build_time_options(cmd, start_seconds, duration_seconds)
_build_metadata(cmd, metadata)
if cover_image_path:
_build_cover_image_mapping(cmd, audio_codec, video_codec, video_quality)
else:
_build_standard_mapping(
cmd, stream_info, audio_codec, video_codec, subtitle_codec,
video_quality, drop_video, drop_subs
)
_build_format(cmd, format_opt)
_build_output(cmd, output_path)
return cmd
+12 -1
View File
@@ -41,7 +41,18 @@ def build_filename(track, idx, extension, args):
clean_filename = apply_replacement(raw_filename,
args.bad_chars,
args.replacement_char)
clean_filename = cleanup_good_chars(clean_filename, args.replacement_char)
# Split off the file extension before cleanup so trailing
# replacement chars don't leak into the name portion.
name_part, _, ext_part = clean_filename.rpartition('.')
clean_filename = cleanup_good_chars(name_part, args.replacement_char)
# Strip leading/trailing separator characters (replacement char,
# hyphen, etc.) that may result from empty fields or bad-char
# sequences adjacent to template separators.
clean_filename = clean_filename.strip(f'{args.replacement_char}-')
# Fallback: if cleanup leaves an empty name, use the track number.
if not clean_filename:
clean_filename = f"{idx:02d}"
clean_filename = f"{clean_filename}.{ext_part}"
else:
clean_filename = raw_filename
# Warn about unsafe characters.
+108 -26
View File
@@ -1,55 +1,137 @@
# audio_splitter/formats.py
"""Container format decision and validation."""
from .constants import FORMAT_INFO
from typing import Dict, Optional
from .constants import (
CONTAINER_NAMES,
CONTAINER_INFO,
CODEC_TO_CONTAINER_MAP,
COMPATIBILITY_MATRIX,
is_audio_codec_supported,
is_video_codec_supported,
)
from .defaults import DEFAULT_FORMAT
def determine_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', 'mkv') 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 CONTAINER_NAMES:
return fmt
if container in CONTAINER_NAMES:
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.
Args:
stream_info: Dict from get_stream_info().
user_format: Userrequested format (or None).
transcode_audio: Audio codec to transcode to (or None).
Returns:
A format name that exists in FORMAT_INFO.
If user_format is provided, map it to the correct container if it's a codec name.
Else, try to detect the input file's container and codec, and use the recommended format.
If detection fails or format is not supported, fallback to:
- MKV if video/subtitles exist
- MP3 if the audio codec is MP3
- MP4 (M4A) otherwise
"""
if user_format:
# Map codec names to their correct containers
# e.g., 'opus' -> 'ogg', 'aac' -> 'mp4', etc.
if user_format in CODEC_TO_CONTAINER_MAP:
return CODEC_TO_CONTAINER_MAP[user_format]
return user_format
# If video or subtitles exist, use MKV (which supports everything).
if stream_info['has_video'] or stream_info['has_subtitle']:
return 'matroska'
if input_file:
try:
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 CONTAINER_NAMES:
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 'mkv'
audio_codec = stream_info.get('audio_codec', '')
if audio_codec == 'mp3':
return 'mp3'
else:
return 'mp4' # .m4a
return 'mp4'
def validate_format_compatibility(format_name, 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:
ValueError: If the format is incompatible with the intended streams.
ValueError: If the combination is incompatible.
"""
info = FORMAT_INFO.get(format_name)
info = next((c for c in CONTAINER_INFO if c['name'] == container), None)
if not info:
print(f"Warning: Unknown format '{format_name}'. Proceeding, but may fail.")
print(f"Warning: Unknown container '{container}'. Proceeding, but may fail.")
return
if info['audio_only']:
if stream_info['has_video'] and not drop_video:
# Check if container is audio-only and video is present (unless dropped)
if not info['supports_video'] and stream_info.get('has_video') and not drop_video:
raise ValueError(
f"Format '{format_name}' does not support video streams. "
"Please use --drop-video or choose a container that supports video."
f"Container '{container}' does not support video streams. "
"Please use --drop-video or choose a container that supports video (e.g., MKV, MP4)."
)
if stream_info['has_subtitle'] and not drop_subs:
# Determine actual audio codec for validation
# When 'copy' is used, we must still validate the input codec against the container
actual_audio_codec = audio_codec if audio_codec and audio_codec != 'copy' else stream_info.get('audio_codec')
if actual_audio_codec:
if not is_audio_codec_supported(container, actual_audio_codec):
raise ValueError(
f"Format '{format_name}' does not support subtitle streams. "
"Please use --drop-subs or choose a container that supports subtitles."
f"Container '{container}' does not support audio codec '{actual_audio_codec}'. "
f"Please choose a different container or audio codec."
)
# Check if video codec is supported (if video is present and not dropped)
if stream_info.get('has_video') and not drop_video:
# Determine the video codec from input file if not provided
if video_codec is None and input_file:
from .ffmpeg import get_video_codec
video_codec = get_video_codec(input_file)
# When 'copy' is used, validate the input video codec against the container
if video_codec == 'copy' and input_file:
from .ffmpeg import get_video_codec
video_codec = get_video_codec(input_file)
if video_codec:
if not is_video_codec_supported(container, video_codec):
raise ValueError(
f"Container '{container}' does not support video codec '{video_codec}'. "
f"Please choose a different container, drop video, or transcode video to a supported codec."
)
+167
View File
@@ -0,0 +1,167 @@
# audio_splitter/handlers.py
"""Handler system for cover image attachment.
This module provides a registry of handlers for different
codec + image combinations. Each handler is responsible for
attaching the cover image to the output file in the appropriate way.
"""
import os
import subprocess
from typing import Dict, Optional
def _run_opustags(input_file: str, cover_image_path: str, output_path: Optional[str] = None) -> bool:
"""
Run opustags to attach a cover image to an Opus file.
Args:
input_file: Path to the input Opus file.
cover_image_path: Path to the cover image.
output_path: Optional output path (if None, overwrites input).
Returns:
True if successful, False otherwise.
"""
cmd = ['opustags', '--set-cover', cover_image_path, input_file]
if output_path:
cmd.extend(['-o', output_path, '-y'])
else:
cmd.extend(['-i'])
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
print(f"opustags failed: {result.stderr}")
return False
return True
def _handler_default(
input_file: str,
cover_image_path: str,
output_path: str,
stream_info: Dict,
audio_codec: str,
video_codec: str,
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> bool:
"""
Default handler: use ffmpeg with -disposition attached_pic.
This is the standard approach for formats like MP3, FLAC, etc.
"""
# This handler doesn't do anything - the cover is already handled
# by the ffmpeg command in core.py via build_ffmpeg_command()
# We just return True to indicate success.
return True
def _handler_opus_with_cover(
input_file: str,
cover_image_path: str,
output_path: str,
stream_info: Dict,
audio_codec: str,
video_codec: str,
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> bool:
"""
Handler for Opus files with cover image.
Uses opustags to attach the cover image after the file is processed.
"""
return _run_opustags(output_path, cover_image_path)
def _handler_mp3_with_cover(
input_file: str,
cover_image_path: str,
output_path: str,
stream_info: Dict,
audio_codec: str,
video_codec: str,
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> bool:
"""
Handler for MP3 files with cover image.
Uses ffmpeg with -disposition attached_pic.
"""
# MP3 handler - the cover is already attached by ffmpeg in core.py
return True
def _handler_flac_with_cover(
input_file: str,
cover_image_path: str,
output_path: str,
stream_info: Dict,
audio_codec: str,
video_codec: str,
video_quality: Optional[int],
drop_video: bool,
drop_subs: bool,
) -> bool:
"""
Handler for FLAC files with cover image.
Uses ffmpeg with -disposition attached_pic.
"""
# FLAC handler - the cover is already attached by ffmpeg in core.py
return True
# Handler registry: maps (output_container, input_codec) to handler function
HANDLER_REGISTRY: Dict[tuple, callable] = {
('opus', 'opus'): _handler_opus_with_cover,
('ogg', 'opus'): _handler_opus_with_cover,
('mp3', 'mp3'): _handler_mp3_with_cover,
('flac', 'flac'): _handler_flac_with_cover,
}
def get_handler(output_container: str, input_audio_codec: Optional[str] = None):
"""
Get the appropriate handler for the given container and input codec.
Args:
output_container: Output container format (e.g., 'opus', 'mp3', 'flac').
input_audio_codec: Input audio codec (e.g., 'opus', 'mp3', 'flac').
Returns:
Handler function, or the default handler if no specific handler is found.
"""
if input_audio_codec:
key = (output_container, input_audio_codec)
if key in HANDLER_REGISTRY:
return HANDLER_REGISTRY[key]
# Fall back to container-only key
if (output_container, None) in HANDLER_REGISTRY:
return HANDLER_REGISTRY[(output_container, None)]
# Default handler
return _handler_default
def needs_drop_video(output_container: str, input_audio_codec: Optional[str] = None) -> bool:
"""
Check if the handler requires dropping video even if user didn't specify --drop-video.
Args:
output_container: Output container format.
input_audio_codec: Input audio codec.
Returns:
True if video should be dropped, False otherwise.
"""
handler = get_handler(output_container, input_audio_codec)
# The opus handler requires dropping video
return handler == _handler_opus_with_cover
+147 -89
View File
@@ -1,139 +1,194 @@
"""Commandline interface and entry point."""
#!/usr/bin/env python3
"""
Audio Splitter CommandLine Interface
Split an audio file into tracks using a tracklist file.
Usage:
audio_splitter input.mp3 tracklist.txt [OPTIONS]
"""
import os
import sys
import subprocess
import argparse
from .constants import DEFAULT_BAD_CHARS
from .tracklist import read_tracklist, parse_format
from .defaults import (
DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE,
DEFAULT_REPLACEMENT_CHAR,
DEFAULT_BAD_CHARS,
DEFAULT_TRACKLIST_FORMAT,
DEFAULT_ALBUM,
DEFAULT_COMMENT,
DEFAULT_COMMENT_STREAM,
DEFAULT_COMMENT_SEPARATOR,
DEFAULT_NO_COMMENT,
DEFAULT_MERGE_COMMENTS,
DEFAULT_DROP_VIDEO,
DEFAULT_DROP_SUBS,
DEFAULT_NUMBER_TRACKS,
DEFAULT_REPLACE_BAD_CHARS,
DEFAULT_SKIP_EXISTING,
DEFAULT_DELETE_ORIGINAL,
)
from .constants import CONTAINER_NAMES
from .core import split_audio
from .tracklist import read_tracklist, parse_format
from .ffmpeg import has_cover_or_video, validate_cover_images
def main():
"""Parse arguments and start the splitting process."""
parser = argparse.ArgumentParser(
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.
parser.add_argument('input_file', help='Input audio/video file')
# Positional
parser.add_argument('input_file', help='Input audio file')
parser.add_argument('tracklist_file', help='Tracklist file')
# Optional arguments.
# Container and codec options
parser.add_argument(
'--output-dir', '-o',
help='Output directory for split tracks (default: <input_basename>_splits)'
'--container',
default=None,
help="Output container format (auto-detect if not specified)"
)
parser.add_argument(
'--format',
help='Output container format (e.g., mp3, m4a, mkv, mp4, ogg, opus)'
'--audio-codec',
default='copy',
help="Audio codec (copy or encoder name, e.g., libopus)"
)
parser.add_argument(
'--transcode-to',
help='Re-encode audio to this codec (e.g., libmp3lame, aac, libopus)'
'--video-codec',
default='copy',
help="Video codec (copy or encoder name, e.g., libx264)"
)
parser.add_argument(
'--subtitle-codec',
default='copy',
help="Subtitle codec (copy or encoder name, e.g., srt)"
)
parser.add_argument(
'--video-quality', '-vq',
type=int,
default=None,
help="Video quality (integer, encoder-specific; usually 1-31, lower=better). "
"If omitted, FFmpeg default is used."
)
parser.add_argument(
'--drop-video',
action='store_true',
help='Remove video streams from output'
default=DEFAULT_DROP_VIDEO,
help="Drop video streams"
)
parser.add_argument(
'--drop-subs',
action='store_true',
help='Remove subtitle streams from output'
default=DEFAULT_DROP_SUBS,
help="Drop subtitle streams"
)
# Filename options
parser.add_argument(
'--number-tracks',
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(
'--replace-bad-chars',
action='store_true',
help='Replace problematic characters in filenames (default: off)'
default=DEFAULT_REPLACE_BAD_CHARS,
help="Replace bad characters"
)
parser.add_argument(
'--replacement-char',
default='_',
help='Character used as replacement (default: "_")'
default=DEFAULT_REPLACEMENT_CHAR,
help="Replacement character (default: %(default)s)"
)
parser.add_argument(
'--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(
'--skip-existing',
action='store_true',
help='Skip extraction if output file already exists (default: overwrite)'
)
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'
default=DEFAULT_SKIP_EXISTING,
help="Skip existing output files"
)
# Metadata options.
parser.add_argument(
'--album',
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)'
)
# Metadata
parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name")
parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment")
parser.add_argument(
'--no-comment',
action='store_true',
help='Explicitly ignore any comment (no comment written)'
default=DEFAULT_NO_COMMENT,
help="Ignore comment"
)
parser.add_argument(
'--comment-stream',
type=int,
default=None,
help='Select comment from a specific stream index (0based). Default: first stream with a comment.'
default=DEFAULT_COMMENT_STREAM,
help="Comment stream index"
)
parser.add_argument(
'--merge-comments',
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(
'--comment-separator',
default='; ',
help='Separator used when merging comments (default: "; ")'
default=DEFAULT_COMMENT_SEPARATOR,
help="Separator for merged comments (default: %(default)s)"
)
# NEW: Delete original after successful split.
# Tracklist format
parser.add_argument(
'--tracklist-format',
default=DEFAULT_TRACKLIST_FORMAT,
help="Tracklist format (default: %(default)s)"
)
# Cover image
parser.add_argument(
'--cover-image',
nargs='+',
metavar='IMAGE',
default=None,
help="Cover image path(s). Single image applied to all tracks, "
"or one per track (must match track count)."
)
# Other
parser.add_argument(
'--delete-original',
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()
# --------------------------------------------------------------------------
# Input validation
# --------------------------------------------------------------------------
# Validate input
if not os.path.exists(args.input_file):
print(f"Error: Input file not found: {args.input_file}")
sys.exit(1)
@@ -142,12 +197,13 @@ def main():
print(f"Error: Tracklist file not found: {args.tracklist_file}")
sys.exit(1)
# Ensure the replacement character is a single character.
if len(args.replacement_char) != 1:
print("Error: --replacement-char must be a single character.")
# Validate container if provided
if args.container and args.container not in CONTAINER_NAMES:
print(f"Error: Invalid container '{args.container}'. "
f"Valid containers: {', '.join(sorted(CONTAINER_NAMES))}.")
sys.exit(1)
# Check that FFmpeg is installed.
# Check FFmpeg
try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
@@ -155,14 +211,14 @@ def main():
print(" - https://ffmpeg.org/download.html")
sys.exit(1)
# Parse the tracklist using the userprovided format.
# Parse tracklist format
try:
tokens = parse_format(args.tracklist_format)
except ValueError as error:
print(f"Error in --tracklist-format: {error}")
except ValueError as e:
print(f"Error in --tracklist-format: {e}")
sys.exit(1)
# Read and parse the tracklist file.
# Read tracklist
tracks = read_tracklist(args.tracklist_file, tokens)
if not tracks:
print("Error: No valid tracks found in tracklist file.")
@@ -170,7 +226,21 @@ def main():
print(f"Found {len(tracks)} tracks.")
# Dryrun mode: display parsed data and exit.
# Validate cover images if provided
if args.cover_image:
# Check if input already has cover image or video track
# Allow if --drop-video is specified (user wants to discard existing video)
if has_cover_or_video(args.input_file) and not args.drop_video:
print("Error: Input file already contains a cover image or video track. "
"Remove it first, or use --drop-video to discard video streams.")
sys.exit(1)
try:
validate_cover_images(args.cover_image, len(tracks))
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
if args.dry_run:
print("\nParsed tracklist:")
print("-" * 60)
@@ -184,34 +254,22 @@ def main():
print(f"{idx:3d} | " + " | ".join(values))
print("-" * 60)
print("Dryrun complete. No files were created.")
sys.exit(0)
return
# Determine the output directory.
# Determine output directory
if args.output_dir:
output_dir = args.output_dir
print(f"Using custom output directory: {output_dir}")
else:
base_name = os.path.splitext(os.path.basename(args.input_file))[0]
output_dir = base_name + "_splits"
print(f"Using default output directory: {output_dir}")
# Run the splitter.
# Run split
try:
split_audio(args.input_file, output_dir, tracks, args)
except (RuntimeError, ValueError) as error:
print(f"Error: {error}")
except Exception as e:
print(f"Error during split: {e}")
sys.exit(1)
# --------------------------------------------------------------------------
# Delete original file if requested and successful.
# --------------------------------------------------------------------------
if args.delete_original:
try:
os.remove(args.input_file)
print(f"Deleted original file: {args.input_file}")
except OSError as e:
print(f"Warning: Could not delete original file: {e}")
print("Done!")
+11
View File
@@ -73,4 +73,15 @@ def resolve_end_times(track_times, total_duration):
if end_sec <= start_sec:
raise ValueError(f"Track {idx+1}: end time ({end_sec}) is not after start ({start_sec})")
resolved.append((start_sec, end_sec))
# Warn about overlapping intervals
for idx in range(len(resolved) - 1):
cur_end = resolved[idx][1]
next_start = resolved[idx + 1][0]
if cur_end > next_start:
print(
f"Warning: Track {idx+1} ends at {cur_end}s but Track {idx+2} starts at "
f"{next_start}s — timestamps overlap by {cur_end - next_start:.1f}s."
)
return resolved
-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:
backend:
image: git.vmn.su/max/audio_splitter_backend:latest
build:
context: .
dockerfile: web/backend/Dockerfile
#image: max/audio_splitter_backend:latest
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes:
# Bind mount for persistent data storage.
# Set BACKEND_DATA_DIR in .env to point to your data directory.
- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
#- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
- "/tmp/test:/tmp/audio_splitter_web"
environment:
- PYTHONUNBUFFERED=1
- DEBUG=${DEBUG:-0}
restart: unless-stopped
frontend:
image: git.vmn.su/max/audio_splitter_frontend:latest
build:
context: .
dockerfile: web/frontend/Dockerfile
#image: max/audio_splitter_frontend:latest
ports:
- "${FRONTEND_PORT:-5173}:80"
depends_on:
-18
View File
@@ -1,18 +0,0 @@
#!/bin/bash
set -e
# Check if we are running as root (default)
if [ "$(id -u)" = "0" ]; then
# If /data is a directory, change its ownership to the container user
if [ -d "/data" ]; then
echo "Setting ownership of /data to appuser:appgroup"
chown -R appuser:appgroup /data
fi
# Drop privileges and run audio_splitter with the provided arguments
exec gosu appuser audio_splitter "$@"
else
# If not root, just run audio_splitter directly
# It's not supposed to be called
exec audio_splitter "$@"
fi
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Generate TypeScript constants from audio_splitter/defaults.py.
"""
import importlib.util
import json
import sys
from pathlib import Path
# Get the directory where this script is located
SCRIPT_DIR = Path(__file__).parent.absolute()
# The defaults.py is in the same directory as the script
DEFAULTS_PATH = SCRIPT_DIR / "defaults.py"
# The frontend source is at SCRIPT_DIR.parent (i.e., /app)
# The generated file should be at /app/src/constants/generated.ts
FRONTEND_ROOT = SCRIPT_DIR.parent
OUTPUT_PATH = FRONTEND_ROOT / "src" / "constants" / "generated.ts"
def load_defaults_module():
spec = importlib.util.spec_from_file_location("defaults", DEFAULTS_PATH)
if spec is None:
raise RuntimeError(f"Could not load spec for {DEFAULTS_PATH}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def format_value(value):
"""Convert Python value to TypeScript literal."""
if value is None:
return "null"
if isinstance(value, bool):
return str(value).lower()
if isinstance(value, str):
# Use json.dumps to produce a properly escaped string literal
return json.dumps(value)
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, list):
return f"[{', '.join(format_value(v) for v in value)}]"
return repr(value)
def main():
print(f"Generating TypeScript defaults from {DEFAULTS_PATH}")
defaults = load_defaults_module()
constants = {name: value for name, value in vars(defaults).items() if name.startswith("DEFAULT_")}
if not constants:
print("No DEFAULT_* constants found.")
sys.exit(1)
lines = [
"// ============================================================================",
"// GENERATED FILE DO NOT EDIT MANUALLY.",
"// This file is generated from audio_splitter/defaults.py.",
"// Run `npm run generate` or `python scripts/generate_ts_defaults.py` to update.",
"// ============================================================================",
"",
]
for name, value in sorted(constants.items()):
ts_value = format_value(value)
lines.append(f"export const {name} = {ts_value};")
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text("\n".join(lines) + "\n")
print(f"✅ Generated {OUTPUT_PATH}")
print(f" {len(constants)} constants exported.")
if __name__ == "__main__":
main()
+2 -1
View File
@@ -1,9 +1,10 @@
FROM python:3.13-slim
# Install FFmpeg, system dependencies, and gosu from APT
# Install FFmpeg, opustags, system dependencies, and gosu from APT
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
opustags \
ca-certificates \
gosu \
&& \
+1 -2
View File
@@ -1,4 +1,3 @@
"""API route handlers."""
from . import upload, split, status, download
# websocket is imported directly in main.py to avoid circular import
from . import upload, split, split_file, status, download, websocket, formats, info
+6 -11
View File
@@ -1,8 +1,9 @@
# web/backend/api/formats.py
"""Endpoint to expose format information to the frontend."""
from fastapi import APIRouter
from backend.constants import FORMAT_INFO
from audio_splitter.constants import CONTAINER_INFO, CODEC_INFO, COMPATIBILITY_MATRIX
router = APIRouter(prefix="/api", tags=["formats"])
@@ -10,16 +11,10 @@ router = APIRouter(prefix="/api", tags=["formats"])
@router.get("/formats")
async def get_formats():
"""
Return the list of supported container formats with their properties.
Return the list of supported containers, codecs, and compatibility matrix.
"""
return {
"formats": [
{
"name": name,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
"containers": CONTAINER_INFO,
"codecs": CODEC_INFO,
"compatibility": COMPATIBILITY_MATRIX,
}
+39 -2
View File
@@ -5,7 +5,12 @@ from fastapi import APIRouter, HTTPException
from backend.config import settings
from backend.services.file_manager import FileManager
from backend.services.task_manager import task_manager
from backend.ffmpeg import get_stream_info
# Import core functions
from audio_splitter.ffmpeg import get_stream_info, get_container_format, get_audio_codec
from audio_splitter.formats import determine_default_format
from audio_splitter.constants import CONTAINER_NAMES
from audio_splitter.defaults import DEFAULT_FORMAT
router = APIRouter(prefix="/api", tags=["info"])
@@ -13,7 +18,8 @@ router = APIRouter(prefix="/api", tags=["info"])
@router.get("/info/{task_id}")
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):
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:
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
@router.get("/info/recommended-format/{task_id}")
async def get_recommended_format(task_id: str):
"""
Return the recommended output format (container name) for the uploaded file,
based on its container and audio codec.
"""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
raise HTTPException(status_code=404, detail="Input file not found")
try:
container = get_container_format(str(input_path))
codec = get_audio_codec(str(input_path))
fmt = determine_default_format(container, codec)
# Fallback if detection fails or format is unsupported
if fmt is None:
fmt = DEFAULT_FORMAT
if fmt not in CONTAINER_NAMES:
fmt = "mp3" # ultimate fallback
return {"format": fmt}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to determine format: {str(e)}")
+30 -2
View File
@@ -1,4 +1,4 @@
"""Split task endpoint."""
"""Split task endpoint (JSON tracklist)."""
from fastapi import APIRouter, HTTPException, BackgroundTasks
@@ -24,6 +24,29 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
detail=f"Task {task_id} is already {status['status']}"
)
# Build options dict from request
options = {
"container": request.container,
"audio_codec": request.audio_codec,
"video_codec": request.video_codec,
"subtitle_codec": request.subtitle_codec,
"video_quality": request.video_quality,
"drop_video": request.drop_video,
"drop_subs": request.drop_subs,
"number_tracks": request.number_tracks,
"replace_bad_chars": request.replace_bad_chars,
"replacement_char": request.replacement_char,
"bad_chars": request.bad_chars,
"skip_existing": request.skip_existing,
"output_template": request.output_template,
"album": request.album,
"comment": request.comment,
"no_comment": request.no_comment,
"comment_stream": request.comment_stream,
"merge_comments": request.merge_comments,
"comment_separator": request.comment_separator,
}
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
@@ -31,7 +54,12 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
message="Preparing to split..."
)
background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options)
background_tasks.add_task(
run_split_task,
task_id,
request.tracklist,
options
)
return SplitResponse(
task_id=task_id,
+151
View File
@@ -0,0 +1,151 @@
"""Endpoint for splitting with a tracklist file upload."""
import json
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.services.splitter import run_split_task
from backend.models.request import TracklistEntry
from backend.models.response import SplitResponse, TaskStatus
router = APIRouter(prefix="/api", tags=["split"])
from audio_splitter.tracklist import parse_format, read_tracklist
@router.post("/split-file", response_model=SplitResponse)
async def split_from_file(
background_tasks: BackgroundTasks,
task_id: str = Form(...),
tracklist_file: UploadFile = File(...),
# New fields
container: Optional[str] = Form(None),
audio_codec: str = Form("copy"),
video_codec: str = Form("copy"),
subtitle_codec: str = Form("copy"),
video_quality: Optional[int] = Form(None),
drop_video: bool = Form(False),
drop_subs: bool = Form(False),
number_tracks: bool = Form(False),
replace_bad_chars: bool = Form(False),
replacement_char: str = Form("_"),
bad_chars: str = Form(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
skip_existing: bool = Form(False),
output_template: str = Form("%an-%tn.%ext"),
album: Optional[str] = Form(None),
comment: Optional[str] = Form(None),
no_comment: bool = Form(False),
comment_stream: Optional[int] = Form(None),
merge_comments: bool = Form(False),
comment_separator: str = Form("; "),
options: str = Form("{}"), # backward-compatible, but we now use explicit fields
tracklist_format: str = Form("%ts %tn - %an"),
):
# Validate task exists
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
raise HTTPException(
status_code=409,
detail=f"Task {task_id} is already {status['status']}"
)
if not tracklist_file.filename.endswith(('.txt', '.text')):
raise HTTPException(
status_code=400,
detail="Tracklist file must be a text file (.txt or .text)"
)
try:
content = await tracklist_file.read()
text = content.decode('utf-8')
except UnicodeDecodeError:
raise HTTPException(
status_code=400,
detail="Tracklist file must be UTF-8 encoded"
)
if not text.strip():
raise HTTPException(
status_code=400,
detail="Tracklist file is empty"
)
# Parse tracklist using CLI logic
try:
tokens = parse_format(tracklist_format)
temp_tracklist_path = settings.temp_dir / task_id / "tracklist.txt"
temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True)
temp_tracklist_path.write_text(text, encoding='utf-8')
tracklist_dicts = read_tracklist(str(temp_tracklist_path), tokens)
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Failed to parse tracklist: {str(e)}"
)
if not tracklist_dicts:
raise HTTPException(
status_code=400,
detail="No valid tracks found in tracklist file"
)
# Convert dicts to TracklistEntry objects
try:
tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts]
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"Invalid tracklist data: {str(e)}"
)
# Build options dict from explicit fields (ignore `options` parameter)
options = {
"container": container,
"audio_codec": audio_codec,
"video_codec": video_codec,
"subtitle_codec": subtitle_codec,
"video_quality": video_quality,
"drop_video": drop_video,
"drop_subs": drop_subs,
"number_tracks": number_tracks,
"replace_bad_chars": replace_bad_chars,
"replacement_char": replacement_char,
"bad_chars": bad_chars,
"skip_existing": skip_existing,
"output_template": output_template,
"album": album,
"comment": comment,
"no_comment": no_comment,
"comment_stream": comment_stream,
"merge_comments": merge_comments,
"comment_separator": comment_separator,
}
# Update task status
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Preparing to split..."
)
background_tasks.add_task(
run_split_task,
task_id,
tracklist_entries,
options
)
return SplitResponse(
task_id=task_id,
status=TaskStatus.PROCESSING
)
+2 -1
View File
@@ -14,7 +14,8 @@ router = APIRouter(prefix="/api", tags=["upload"])
ALLOWED_EXTENSIONS = {
".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus",
".aac", ".wma", ".aiff", ".alac", ".ac3"
".aac", ".wma", ".aiff", ".alac", ".ac3",
".mp4", ".mkv", ".webm",
}
-12
View File
@@ -1,12 +0,0 @@
FORMAT_INFO = {
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
}
+4 -3
View File
@@ -5,7 +5,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from 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
app = FastAPI(
@@ -33,11 +33,12 @@ async def startup_event():
# Include routers
app.include_router(upload.router)
app.include_router(split.router)
app.include_router(split_file.router) # NEW
app.include_router(status.router)
app.include_router(download.router)
app.include_router(websocket.router)
app.include_router(formats.router) # new
app.include_router(info.router) # new
app.include_router(formats.router)
app.include_router(info.router)
@app.get("/")
+27 -5
View File
@@ -17,19 +17,16 @@ class TracklistEntry(BaseModel):
@validator("ts")
def validate_timestamp(cls, v: str) -> str:
"""Basic timestamp validation (format and range)."""
v = v.strip()
if not v:
raise ValueError("Timestamp cannot be empty")
# Check for range format (start-end)
if "-" in v:
parts = v.split("-", 1)
start = parts[0].strip()
end = parts[1].strip()
if not start or not end:
raise ValueError("Invalid range format. Expected 'start-end'")
# Validate each part with the same logic
for ts in [start, end]:
cls._validate_single_timestamp(ts)
else:
@@ -39,7 +36,6 @@ class TracklistEntry(BaseModel):
@staticmethod
def _validate_single_timestamp(ts: str) -> None:
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
parts = ts.split(":")
if len(parts) not in (2, 3):
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS")
@@ -55,4 +51,30 @@ class SplitRequest(BaseModel):
task_id: str = Field(..., description="Task ID from upload")
tracklist: List[TracklistEntry] = Field(..., description="List of tracks")
options: dict = Field(default_factory=dict, description="All CLI options")
# Container and codec options
container: Optional[str] = Field(None, description="Output container (auto-detect if None)")
audio_codec: Optional[str] = Field("copy", description="Audio codec (copy or encoder name)")
video_codec: Optional[str] = Field("copy", description="Video codec (copy or encoder name)")
subtitle_codec: Optional[str] = Field("copy", description="Subtitle codec (copy or encoder name)")
video_quality: Optional[int] = Field(None, description="Video quality (encoder-specific integer)")
# Stream handling
drop_video: bool = Field(False, description="Remove video streams")
drop_subs: bool = Field(False, description="Remove subtitle streams")
# Filename options
number_tracks: bool = Field(False, description="Prepend track numbers")
replace_bad_chars: bool = Field(False, description="Replace bad characters")
replacement_char: str = Field("_", description="Replacement character")
bad_chars: str = Field(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ', description="Bad characters to replace")
skip_existing: bool = Field(False, description="Skip existing files")
output_template: str = Field("%an-%tn.%ext", description="Output filename template")
# Metadata options
album: Optional[str] = Field(None, description="Album name")
comment: Optional[str] = Field(None, description="Comment")
no_comment: bool = Field(False, description="Ignore comment")
comment_stream: Optional[int] = Field(None, description="Comment stream index")
merge_comments: bool = Field(False, description="Merge all comments")
comment_separator: str = Field("; ", description="Separator for merged comments")
+10 -7
View File
@@ -40,9 +40,13 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from types import SimpleNamespace
# Build args namespace using the new fields
args = SimpleNamespace(
format=options.get("format", "mp3"),
transcode_to=options.get("transcode_to", None),
container=options.get("container"), # None -> auto-detect
audio_codec=options.get("audio_codec", "copy"),
video_codec=options.get("video_codec", "copy"),
subtitle_codec=options.get("subtitle_codec", "copy"),
video_quality=options.get("video_quality"),
drop_video=options.get("drop_video", False),
drop_subs=options.get("drop_subs", False),
number_tracks=options.get("number_tracks", False),
@@ -66,17 +70,18 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from audio_splitter.core import split_audio
# Cover image handling is now done internally by split_audio()
# via the handler system (opustags for Opus, ffmpeg for others)
args.cover_image = None
task_manager.update_task_with_progress(
task_id, progress=10, message="Starting split..."
)
# Run the split
split_audio(str(input_path), str(output_dir), tracks, args)
# Get output files
output_files = FileManager.get_output_files(task_id)
# Final status update
task_manager.update_task_with_progress(
task_id,
progress=100,
@@ -84,13 +89,11 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
status=TaskStatus.DONE
)
# Add tracks to the task state
task_manager.update_task(
task_id,
tracks=output_files
)
# Give WebSocket time to send the final message
time.sleep(0.5)
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
# Install Python for the generation script
RUN apk add --no-cache python3 py3-pip
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
# Copy frontend package files and install dependencies
COPY web/frontend/package.json web/frontend/package-lock.json* ./
RUN npm install
# Copy the application code and build
COPY . .
# Copy the frontend source code
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
# Stage 2: Production (nginx)
FROM nginx:alpine
# Copy built assets from builder
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
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+3
View File
@@ -4,6 +4,9 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"generate": "python scripts/generate_ts_defaults.py",
"predev": "npm run generate",
"prebuild": "npm run generate",
"dev": "vite",
"build": "tsc && vite build",
"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()
}
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError
const isSplitDisabled =
!taskId ||
!isValid ||
entries.length === 0 ||
isProcessing ||
!!formatError
return (
<ThemeProvider
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<ThemeProvider theme={createTheme({ palette: { mode: theme } })}>
<CssBaseline />
<Layout>
<Grid container spacing={3}>
+39 -10
View File
@@ -1,5 +1,12 @@
import axios from 'axios'
import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
import {
TracklistEntry,
SplitOptions,
TaskStatus,
FormatsResponse,
UploadResponse,
SplitResponse,
} from '../types'
export const api = axios.create({
baseURL: '/api',
@@ -8,16 +15,14 @@ export const api = axios.create({
},
})
export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => {
export const uploadFile = async (file: File): Promise<UploadResponse> => {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
@@ -25,12 +30,32 @@ export const startSplit = async (
task_id: string,
tracklist: TracklistEntry[],
options: SplitOptions
): Promise<{ task_id: string; status: string }> => {
const response = await api.post('/split', {
): Promise<SplitResponse> => {
// Build request payload (only send fields that are not undefined)
const payload: any = {
task_id,
tracklist,
options,
})
container: options.container, // null means auto-detect
audio_codec: options.audio_codec,
video_codec: options.video_codec,
subtitle_codec: options.subtitle_codec,
video_quality: options.video_quality,
drop_video: options.drop_video,
drop_subs: options.drop_subs,
number_tracks: options.number_tracks,
replace_bad_chars: options.replace_bad_chars,
replacement_char: options.replacement_char,
bad_chars: options.bad_chars,
skip_existing: options.skip_existing,
output_template: options.output_template,
album: options.album || null,
comment: options.comment || null,
no_comment: options.no_comment,
comment_stream: options.comment_stream,
merge_comments: options.merge_comments,
comment_separator: options.comment_separator,
}
const response = await api.post('/split', payload)
return response.data
}
@@ -47,8 +72,7 @@ export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip`
}
// New functions for format validation feature
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
export const getFormats = async (): Promise<FormatsResponse> => {
const response = await api.get('/formats')
return response.data
}
@@ -63,3 +87,8 @@ export const getTaskInfo = async (task_id: string): Promise<{
const response = await api.get(`/info/${task_id}`)
return response.data
}
export const getRecommendedFormat = async (task_id: string): Promise<{ format: string }> => {
const response = await api.get(`/info/recommended-format/${task_id}`)
return response.data
}
+136 -73
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'
import React, { useEffect, useMemo } from 'react'
import {
Box,
Paper,
@@ -16,7 +16,14 @@ import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore'
import { getFormats } from '../api/client'
import { SplitOptions } from '../types'
import { useFilterCodecs } from '../hooks/useFilterCodecs'
import { useFormatValidation } from '../hooks/useFormatValidation'
// ------------------------------------------------------------------------------
// Section component (collapsible)
// ------------------------------------------------------------------------------
interface SectionProps {
title: string
children: React.ReactNode
@@ -25,7 +32,6 @@ interface SectionProps {
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded)
return (
<Box sx={{ mb: 2 }}>
<Box
@@ -51,54 +57,64 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
)
}
// ------------------------------------------------------------------------------
// Main OptionsPanel
// ------------------------------------------------------------------------------
export const OptionsPanel: React.FC = () => {
const { options, setOptions } = useOptionsStore()
const {
options,
setOptions,
containers,
codecs,
compatibility,
setContainers,
setCodecs,
setCompatibility,
} = useOptionsStore()
const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore()
// Audio-only formats from backend constants (hardcoded for now)
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
const handleFormatChange = (e: React.ChangeEvent<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)
// Fetch formats on mount
useEffect(() => {
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
const newError = shouldShowError
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
: null
// Only update if the error state actually changes
if (newError !== formatError) {
setFormatError(newError)
const fetchFormats = async () => {
try {
const data = await getFormats()
setContainers(data.containers || [])
setCodecs(data.codecs || [])
setCompatibility(data.compatibility || {})
} catch (err) {
console.error('Failed to fetch formats:', err)
}
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
}
fetchFormats()
}, [setContainers, setCodecs, setCompatibility])
// --------------------------------------------------------------
// Filter codec options based on selected container
// --------------------------------------------------------------
const { filteredAudioCodecs, filteredVideoCodecs } = useFilterCodecs(compatibility, options.container, codecs)
// --------------------------------------------------------------
// Validate compatibility
// --------------------------------------------------------------
useFormatValidation(options, hasVideo, compatibility, containers, setFormatError)
// --------------------------------------------------------------
// Handlers
// --------------------------------------------------------------
const handleChange = (field: keyof SplitOptions, value: any) => {
setOptions({ [field]: value })
}
const handleContainerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value === '' ? null : e.target.value
handleChange('container', value)
}
// --------------------------------------------------------------
// Render
// --------------------------------------------------------------
return (
<Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
@@ -111,55 +127,101 @@ export const OptionsPanel: React.FC = () => {
</Alert>
)}
{/* Output Settings */}
{/* ------------------------------------------------------------------------
Output Settings
------------------------------------------------------------------------ */}
<Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Container dropdown */}
<TextField
label="Format"
label="Container"
select
value={options.format}
onChange={handleFormatChange}
value={options.container ?? ''}
onChange={handleContainerChange}
fullWidth
size="small"
error={!!formatError}
helperText={
formatError || "Select a container (auto-detect if empty)."
}
>
<MenuItem value="mp3">MP3</MenuItem>
<MenuItem value="m4a">M4A</MenuItem>
<MenuItem value="mkv">MKV</MenuItem>
<MenuItem value="mp4">MP4</MenuItem>
<MenuItem value="ogg">OGG</MenuItem>
<MenuItem value="opus">OPUS</MenuItem>
<MenuItem value="flac">FLAC</MenuItem>
<MenuItem value="wav">WAV</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
<MenuItem value="">Auto-detect</MenuItem>
{containers.map((c) => (
<MenuItem key={c.name} value={c.name}>
{c.name.toUpperCase()} ({c.extension})
</MenuItem>
))}
</TextField>
{/* Audio Codec dropdown */}
<TextField
label="Transcode to"
label="Audio Codec"
select
value={options.transcode_to || ''}
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
value={options.audio_codec}
onChange={(e) => handleChange('audio_codec', e.target.value)}
fullWidth
size="small"
helperText="Audio codec (copy = keep original)"
>
<MenuItem value="">Copy (no transcoding)</MenuItem>
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
<MenuItem value="libopus">OPUS</MenuItem>
<MenuItem value="copy">Copy (original)</MenuItem>
{filteredAudioCodecs.map((c) => (
<MenuItem key={c.name} value={c.name}>
{c.name.toUpperCase()}
</MenuItem>
))}
</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 && (
<FormControlLabel
control={
<Switch
checked={options.drop_video}
onChange={handleDropVideoChange}
onChange={(e) => handleChange('drop_video', e.target.checked)}
/>
}
label="Drop video streams"
/>
)}
{/* Drop subtitles */}
<FormControlLabel
control={
<Switch
@@ -172,7 +234,9 @@ export const OptionsPanel: React.FC = () => {
</Box>
</Section>
{/* Filename Settings */}
{/* ------------------------------------------------------------------------
Filename Settings
------------------------------------------------------------------------ */}
<Section title="Filename Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
@@ -228,7 +292,9 @@ export const OptionsPanel: React.FC = () => {
</Box>
</Section>
{/* Metadata Settings */}
{/* ------------------------------------------------------------------------
Metadata Settings
------------------------------------------------------------------------ */}
<Section title="Metadata Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
@@ -259,7 +325,7 @@ export const OptionsPanel: React.FC = () => {
type="number"
value={options.comment_stream ?? ''}
onChange={(e) =>
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value, 10))
}
size="small"
disabled={options.no_comment}
@@ -284,7 +350,9 @@ export const OptionsPanel: React.FC = () => {
</Box>
</Section>
{/* Tracklist Settings */}
{/* ------------------------------------------------------------------------
Tracklist Settings
------------------------------------------------------------------------ */}
<Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
@@ -299,9 +367,4 @@ export const OptionsPanel: React.FC = () => {
</Section>
</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 { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore'
@@ -10,6 +10,9 @@ export const TracklistEditor: React.FC = () => {
const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false)
const textAreaRef = useRef<HTMLTextAreaElement>(null)
const lineNumbersRef = useRef<HTMLDivElement>(null)
const validate = (text: string) => {
const result = parseAndValidateTracklist(text, options.tracklist_format)
setEntries(result.entries)
@@ -23,10 +26,29 @@ export const TracklistEditor: React.FC = () => {
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(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
const file = acceptedFiles[0]
const reader = new FileReader()
reader.onload = (event) => {
@@ -48,13 +70,6 @@ export const TracklistEditor: React.FC = () => {
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
return (
@@ -82,12 +97,13 @@ export const TracklistEditor: React.FC = () => {
</Typography>
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
<Box sx={{ display: 'flex', gap: 2, position: 'relative' }}>
{/* Line numbers column */}
<Box
ref={lineNumbersRef}
sx={{
minWidth: 40,
maxWidth: 40,
maxWidth: 60, // Allow more space for 3-digit numbers
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: 1.7,
@@ -95,6 +111,11 @@ export const TracklistEditor: React.FC = () => {
textAlign: 'right',
userSelect: 'none',
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) => (
@@ -102,7 +123,7 @@ export const TracklistEditor: React.FC = () => {
))}
</Box>
{/* Editor text area */}
{/* Editor text area now with horizontal scroll and no wrap */}
<TextField
multiline
fullWidth
@@ -112,11 +133,19 @@ export const TracklistEditor: React.FC = () => {
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`}
variant="outlined"
inputRef={textAreaRef}
sx={{
'& .MuiInputBase-root': {
fontFamily: 'monospace',
fontSize: '14px',
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}
+17 -5
View File
@@ -3,10 +3,11 @@ import { useDropzone } from 'react-dropzone'
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
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'
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3', '.mp4', '.mkv', '.webm']
export const UploadZone: React.FC = () => {
const {
@@ -30,6 +31,7 @@ export const UploadZone: React.FC = () => {
} = useUploadStore()
const { setTaskId: setTaskIdStore } = useTaskStore()
const { setOptions } = useOptionsStore() // NEW
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
@@ -65,9 +67,19 @@ export const UploadZone: React.FC = () => {
setHasAudio(info.has_audio)
setHasSubtitle(info.has_subtitle)
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) {
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) {
setError(err.response?.data?.detail || err.message || 'Upload failed')
@@ -78,13 +90,13 @@ export const UploadZone: React.FC = () => {
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({
onDrop,
accept: {
'audio/*': ALLOWED_EXTENSIONS,
'media/*': ALLOWED_EXTENSIONS
},
multiple: false,
disabled: isUploading,
+31
View File
@@ -0,0 +1,31 @@
import { useMemo } from 'react'
import { CodecInfo } from '../types'
export interface CompatibilityEntry {
audio: string[] | null
video: string[] | null
}
export function useFilterCodecs(
compatibility: Record<string, CompatibilityEntry>,
container: string | null,
codecs: CodecInfo[]
) {
const filteredAudioCodecs = useMemo(() => {
const entry = compatibility?.[container || '']
if (!entry) return codecs.filter(c => c.codec_type === 'audio')
const audioList = entry.audio
if (audioList === null) return codecs.filter(c => c.codec_type === 'audio')
return codecs.filter(c => c.codec_type === 'audio' && audioList.includes(c.name))
}, [compatibility, container, codecs])
const filteredVideoCodecs = useMemo(() => {
const entry = compatibility?.[container || '']
if (!entry) return codecs.filter(c => c.codec_type === 'video')
const videoList = entry.video
if (videoList === null) return codecs.filter(c => c.codec_type === 'video')
return codecs.filter(c => c.codec_type === 'video' && videoList.includes(c.name))
}, [compatibility, container, codecs])
return { filteredAudioCodecs, filteredVideoCodecs }
}
@@ -0,0 +1,56 @@
import { useEffect } from 'react'
import { ContainerInfo } from '../types'
import { CompatibilityEntry } from './useFilterCodecs'
export function useFormatValidation(
options: {
container: string | null
audio_codec: string
video_codec: string
drop_video: boolean
},
hasVideo: boolean,
compatibility: Record<string, CompatibilityEntry>,
containers: ContainerInfo[],
setFormatError: (error: string | null) => void
) {
useEffect(() => {
const selectedContainer = containers.find(c => c.name === options.container)
if (!selectedContainer?.supports_video && hasVideo && !options.drop_video) {
setFormatError(
`Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.`
)
return
}
// Check audio codec compatibility
if (options.audio_codec && options.audio_codec !== 'copy' && options.container) {
const entry = compatibility?.[options.container]
if (entry) {
const audioList = entry.audio
if (audioList !== null && !audioList.includes(options.audio_codec)) {
setFormatError(
`Audio codec '${options.audio_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
// Check video codec compatibility
if (options.video_codec && options.video_codec !== 'copy' && options.container && hasVideo && !options.drop_video) {
const entry = compatibility?.[options.container]
if (entry) {
const videoList = entry.video
if (videoList !== null && !videoList.includes(options.video_codec)) {
setFormatError(
`Video codec '${options.video_codec}' is not supported by container '${options.container}'.`
)
return
}
}
}
setFormatError(null)
}, [options.container, options.audio_codec, options.video_codec, options.drop_video, hasVideo, compatibility, containers, setFormatError])
}
+60 -20
View File
@@ -1,37 +1,77 @@
import { create } from 'zustand'
import { 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 = {
format: 'mp3',
transcode_to: '',
drop_video: false,
drop_subs: false,
number_tracks: false,
replace_bad_chars: false,
replacement_char: '_',
bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ',
skip_existing: false,
output_template: '%an-%tn.%ext',
album: '',
comment: '',
no_comment: false,
comment_stream: null,
merge_comments: false,
comment_separator: '; ',
tracklist_format: '%ts %tn - %an', // NEW
container: null,
audio_codec: 'copy',
video_codec: 'copy',
subtitle_codec: 'copy',
video_quality: null,
drop_video: DEFAULT_DROP_VIDEO,
drop_subs: DEFAULT_DROP_SUBS,
number_tracks: DEFAULT_NUMBER_TRACKS,
replace_bad_chars: DEFAULT_REPLACE_BAD_CHARS,
replacement_char: DEFAULT_REPLACEMENT_CHAR,
bad_chars: DEFAULT_BAD_CHARS,
skip_existing: DEFAULT_SKIP_EXISTING,
output_template: DEFAULT_OUTPUT_TEMPLATE,
album: DEFAULT_ALBUM ?? '',
comment: DEFAULT_COMMENT ?? '',
no_comment: DEFAULT_NO_COMMENT,
comment_stream: DEFAULT_COMMENT_STREAM,
merge_comments: DEFAULT_MERGE_COMMENTS,
comment_separator: DEFAULT_COMMENT_SEPARATOR,
tracklist_format: DEFAULT_TRACKLIST_FORMAT,
}
interface OptionsState {
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
}
export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
setOptions: (newOptions) =>
set((state) => ({
options: { ...state.options, ...newOptions },
})),
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
setContainers: (containers) => set({ containers }),
setCodecs: (codecs) => set({ codecs }),
setCompatibility: (compatibility) => set({ compatibility }),
reset: () =>
set({
options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
compatibility: {},
}),
}))
+33 -3
View File
@@ -22,8 +22,11 @@ export interface TaskStatus {
}
export interface SplitOptions {
format: string
transcode_to?: string
container: string | null // null = auto-detect
audio_codec: string // 'copy' or encoder name
video_codec: string // 'copy' or encoder name
subtitle_codec: string // 'copy' or encoder name
video_quality: number | null // encoder-specific integer
drop_video: boolean
drop_subs: boolean
number_tracks: boolean
@@ -38,7 +41,34 @@ export interface SplitOptions {
comment_stream: number | null
merge_comments: boolean
comment_separator: string
tracklist_format: string
tracklist_format: string // for frontend parsing
}
export interface ContainerInfo {
name: string
ffmpeg: string
extension: string
supports_video: boolean
supports_subs: boolean
}
export interface CodecInfo {
name: string
ffmpeg: string
recommended_container: string
recommended_extension: string
codec_type: 'audio' | 'video' | 'subtitle'
}
export interface CompatibilityEntry {
audio: string[] | null
video: string[] | null
}
export interface FormatsResponse {
containers: ContainerInfo[]
codecs: CodecInfo[]
compatibility: Record<string, CompatibilityEntry>
}
export interface UploadResponse {