39 Commits

Author SHA1 Message Date
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
max f868de9d3b Merge pull request 'Reverse proxy config added, secret is used in build images action' (#3) from reverse-proxy into main
Reviewed-on: #3
2026-08-05 12:08:10 +00:00
max 800ad9c280 Building images action uses a secret to authenticate webhook server
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 1m2s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 49s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 13s
2026-08-05 16:57:12 +05:00
max bf6c6780da New deployment instructions added - simple NGINX reverse proxy example 2026-08-05 14:33:03 +05:00
max 96b1588fcc Merge pull request 'Ci cd merge request' (#2) from ci-cd into main
Reviewed-on: #2
2026-08-05 04:57:46 +00:00
max f00a70cb2e Merge pull request 'Web interface merge' (#1) from web-interface into main
Reviewed-on: #1
2026-08-05 04:56:17 +00:00
max 7f2b82aae0 .env.example is improved, comments added. docker-compose-prod.yaml is replaced with docker-compose.yaml, development version is docker-compose.override.yaml now
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 21m4s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 52s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 13s
2026-08-05 09:52:23 +05:00
max a028b875fb building image actions is triggered only if a pull request has been merged into main 2026-08-05 09:07:00 +05:00
max d1a166a0f1 docker-compose-prod.yaml uses more variables from .env.example now 2026-08-05 08:52:45 +05:00
max fb9413e6f8 .env is untracked now 2026-08-04 21:50:06 +05:00
max e0490c5f41 .env.example is committed to the repo from now on. .env is used as a development file and ignored 2026-08-04 21:46:48 +05:00
max b6deed78c8 Building image action builds both images first, only then notifies the deployment server. Docker compose production file added 2026-08-04 20:41:21 +05:00
max e802b684ff Docker build context fixed in frontend building job
Build and Push Docker Image / build-and-push (push) Successful in 4m46s
2026-08-04 20:18:11 +05:00
max e28bcc27fc fixes in the build image action
Build and Push Docker Image / build-and-push (push) Failing after 1m27s
2026-08-04 20:12:41 +05:00
max 3a326859de Building image action fixed to use correct Dockerfile and build context
Build and Push Docker Image / build-and-push (push) Failing after 19m37s
2026-08-04 19:50:41 +05:00
max 26fb4fb3f4 Test CI/CD job was removed. Image building job was updated to build web version of the app
Build and Push Docker Image / build-and-push (push) Failing after 39s
2026-08-04 19:17:36 +05:00
max c455bd541b New test action added. It builds a docker image and pushes it to the registry
Build and Push Docker Image / build-and-push (push) Successful in 20m15s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 15s
2026-08-04 10:56:01 +05:00
max c66dbf9dc3 Test CI/CD file added
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 18s
2026-08-03 13:02:48 +05:00
max dcdae41706 Tracs validation added. A warning is thrown if the input contains a video strem, but the requested output format doesn't support it 2026-08-03 11:26:17 +05:00
max 1d1f8563c0 Backend: endpoint for single output download added 2026-08-03 10:53:13 +05:00
max e23b0f44ce Download section didn't appear. Resolved 2026-08-03 10:46:01 +05:00
max ea49920a12 Useless Dockerfile has been removed. .gitignore updated 2026-08-02 13:38:43 +05:00
max 6707a8e27e Working docker-compose.yaml added. Services start and work. 2026-08-02 13:19:19 +05:00
max cd3ce0337b Frontend and backend work together fine, complete workflow is implemented 2026-08-01 17:04:03 +05:00
max e87d8089bf Simple frontend added, tests are required 2026-08-01 08:41:25 +00:00
max b6ace3f68b Bugs are fixed in the backend, it works now 2026-08-01 08:40:53 +00:00
max cd56637d2a Simple web backend added with upload/status/process/download API 2026-07-30 12:24:37 +00:00
max 277c538f21 Merge pull request 'Docker package' (#2) from docker_package into main
Reviewed-on: #2
2026-07-30 10:28:15 +00:00
max bb4adb1886 README.md includes information about docker setup 2026-07-30 10:22:01 +00:00
max 6c72834933 test_data added to .dockerignore 2026-07-30 09:57:59 +00:00
max 9d30844a1e Docker image hardened, seems to be working stable 2026-07-30 09:46:31 +00:00
max 5b0bb25177 Test docker image, basic functionality 2026-07-29 20:05:17 +05:00
72 changed files with 7793 additions and 318 deletions
+45
View File
@@ -0,0 +1,45 @@
# Git
.git/
.gitignore
# Python cache
*.pyc
__pycache__/
*.pyo
*.pyd
# Build artifacts
build/
dist/
*.egg-info/
.eggs/
# Virtual environments
.venv
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
# License (not needed for installation)
LICENSE
# Docker files (not needed in build context)
Dockerfile
.dockerignore
# Local test files
test_data/
*.mp3
*.flac
*.wav
*.txt
*.log
# Deployment
deploy/
+26
View File
@@ -0,0 +1,26 @@
# ------------------------------------------------------------------------------
# Production Environment Variables
# ------------------------------------------------------------------------------
# Backend
DEBUG=0
MAX_UPLOAD_SIZE_MB=500
CLEANUP_AFTER_SECONDS=3600
# Backend data directory (bind mount)
# This directory will store all temporary files during splitting.
# It can get large (audio files), so place it on a partition with enough space.
BACKEND_DATA_DIR=/var/lib/audio_splitter_data
# Ports
BACKEND_PORT=8000
FRONTEND_PORT=5173
# ------------------------------------------------------------------------------
# Development Overrides (docker-compose.override.yaml)
# ------------------------------------------------------------------------------
# These are only used when docker-compose.override.yaml is present.
# They override the production settings for local development.
# Frontend (development)
VITE_BACKEND_URL=http://localhost:8000
+64
View File
@@ -0,0 +1,64 @@
name: Build and Push Docker Image
run-name: Production images are being built
on:
pull_request:
types: [closed]
branches: [ main ]
env:
REGISTRY: git.vmn.su
OWNER: max
REPO: audio_splitter
jobs:
build-and-push-backend:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
run: |
echo "${{ secrets.CI_PUSHER_GITEA_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Build backend image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:${{ github.sha }} -f ./web/backend/Dockerfile .
docker tag ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:latest
- name: Push backend image
run: |
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:latest
build-and-push-frontend:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
run: |
echo "${{ secrets.CI_PUSHER_GITEA_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Build frontend image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:${{ github.sha }} -f ./web/frontend/Dockerfile ./web/frontend
docker tag ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:latest
- name: Push frontend image
run: |
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:latest
notify-deployment-server:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Trigger the webhook on the deployment server to start pulling the images
run: curl "https://webhook.vmn.su/hooks/deploy-audio-splitter?token=${{ secrets.CD_WEBHOOK_TOKEN }}"
+5
View File
@@ -3,3 +3,8 @@ build/
dist/ dist/
*.egg-info/ *.egg-info/
*.pyc *.pyc
test_data/
venv/
web/frontend/node_modules
TODO.md
.env
+13 -237
View File
@@ -2,7 +2,8 @@
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
[![FFmpeg](https://img.shields.io/badge/FFmpeg-required-green.svg)](https://ffmpeg.org/) [![FFmpeg](https://img.shields.io/badge/FFmpeg-required-green.svg)](https://ffmpeg.org/)
[![License: MIT](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://opensource.org/license/gpl-3.0) [![License: GPLv3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Docker](https://img.shields.io/badge/Docker-ready-blue.svg)](https://www.docker.com/)
**Split audio files into tracks using a flexible tracklist format.** **Split audio files into tracks using a flexible tracklist format.**
@@ -10,6 +11,10 @@
--- ---
## 📖 Documentation
Full documentation including all CLI options, tracklist formats, deployment guides, and architecture details is available in the [**Wiki**](https://git.vmn.su/max/audio_splitter/wiki).
## ✨ Features ## ✨ Features
- **Flexible tracklist parsing** Define your own format with placeholders (`%ts`, `%tn`, `%an`, `%al`, `%date`, `%ext`) - **Flexible tracklist parsing** Define your own format with placeholders (`%ts`, `%tn`, `%an`, `%al`, `%date`, `%ext`)
@@ -20,242 +25,12 @@
- **Character replacement** Replace problematic filename characters with a custom character - **Character replacement** Replace problematic filename characters with a custom character
- **Dryrun mode** Preview parsed tracks without splitting - **Dryrun mode** Preview parsed tracks without splitting
- **Skip existing files** Avoid overwriting alreadyextracted tracks - **Skip existing files** Avoid overwriting alreadyextracted tracks
- **Delete original** Optionally remove the input file after successful splitting
- **Docker image** Run without installing dependencies; simple oneline commands
--- ---
## 📦 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://github.com/yourusername/audio-splitter.git
cd audio-splitter
pip install .
```
Now the `audio_splitter` command is available globally:
```bash
audio_splitter input.mp3 tracks.txt
```
Or run directly without installation:
```bash
python -m audio_splitter.main input.mp3 tracklist.txt
```
---
## 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
# If not installed
python -m audio_splitter.main my_album.mp3 tracks.txt
# If installed
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 |
---
## 📝 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
python -m audio_splitter.main input.flac tracks.txt \
--tracklist-format "%an - %tn [%ts]"
```
### Custom output filenames
Name files as `01 - Artist - Song.mp3`:
```bash
python -m audio_splitter.main input.flac tracks.txt \
--output-template "%num - %an - %tn.%ext"
```
### Override album and comment
```bash
python -m audio_splitter.main input.flac tracks.txt \
--album "Greatest Hits" \
--comment "Live recording"
```
### Merge multiple comments from input file
```bash
python -m audio_splitter.main input.flac tracks.txt \
--merge-comments \
--comment-separator " | "
```
### Dryrun to preview parsing
```bash
python -m audio_splitter.main 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.
```
---
## 📂 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
```
---
## 🤝 Contributing ## 🤝 Contributing
@@ -267,13 +42,13 @@ Contributions are welcome! Please follow these steps:
4. Push to the branch (`git push origin feature/amazing-feature`) 4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request 5. Open a Pull Request
For bug reports or feature requests, please [open an issue](https://github.com/yourusername/audio-splitter/issues). For bug reports or feature requests, please [open an issue](https://git.vmn.su/max/audio_splitter/issues).
--- ---
## 📄 License ## 📄 License
Distributed under the GPU GPL v3 or later. See [LICENSE](LICENSE) for more information. Distributed under the GNU General Public License v3 (or later). See the [LICENSE](LICENSE) file for more details.
--- ---
@@ -281,10 +56,11 @@ Distributed under the GPU GPL v3 or later. See [LICENSE](LICENSE) for more infor
- [FFmpeg](https://ffmpeg.org/) the powerhouse behind audio processing - [FFmpeg](https://ffmpeg.org/) the powerhouse behind audio processing
- [Python](https://www.python.org/) the language that makes it all possible - [Python](https://www.python.org/) the language that makes it all possible
- [gosu](https://github.com/tianon/gosu) privilege dropping for Docker
--- ---
## 📬 Contact ## 📬 Contact
**Maintainer:** [Maxim Vershinin](https://git.vmn.su/max) **Maintainer:** Maxim Vershinin
**Project Link:** [https://git.vmn.su/max/audio-splitter](https://git.vmn.su/max/audio-splitter) **Project Link:** [https://git.vmn.su/max/audio_splitter](https://git.vmn.su/max/audio_splitter)
+1 -1
View File
@@ -44,7 +44,7 @@ def split_audio(input_file, output_directory, tracks, args):
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# 2. Format decision # 2. Format decision
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
output_format = determine_output_format(stream_info, args.format, args.transcode_to) output_format = determine_output_format(stream_info, args.format, args.transcode_to, input_file=input_file)
print(f"Output container: {output_format}") print(f"Output container: {output_format}")
validate_format_compatibility(output_format, stream_info, validate_format_compatibility(output_format, stream_info,
+107 -71
View File
@@ -1,7 +1,8 @@
"""FFmpeg / FFprobe interactions and command building.""" """FFmpeg/FFprobe interaction utilities for the CLI and web backend."""
import subprocess
import json import json
import subprocess
from typing import Dict, List, Optional, Tuple
from .constants import FORMAT_INFO from .constants import FORMAT_INFO
from .utils import format_time from .utils import format_time
@@ -24,7 +25,10 @@ def get_audio_duration(input_file: str) -> float:
input_file input_file
] ]
result = subprocess.run(cmd, capture_output=True, text=True, check=False) result = subprocess.run(cmd, capture_output=True, text=True, check=False)
return float(result.stdout.strip()) try:
return float(result.stdout.strip())
except ValueError:
return 0.0
def has_stream_type(input_file: str, stream_type: str) -> bool: def has_stream_type(input_file: str, stream_type: str) -> bool:
@@ -49,7 +53,7 @@ def has_stream_type(input_file: str, stream_type: str) -> bool:
return bool(result.stdout.strip()) return bool(result.stdout.strip())
def get_audio_codec(input_file: str) -> str: def get_audio_codec(input_file: str) -> Optional[str]:
""" """
Return the codec name of the first audio stream. Return the codec name of the first audio stream.
@@ -71,7 +75,7 @@ def get_audio_codec(input_file: str) -> str:
return codec if codec else None return codec if codec else None
def get_stream_info(input_file: str): def get_stream_info(input_file: str) -> Dict[str, any]:
""" """
Collect information about the streams present in the input file. Collect information about the streams present in the input file.
@@ -88,9 +92,97 @@ def get_stream_info(input_file: str):
'audio_codec': get_audio_codec(input_file) 'audio_codec': get_audio_codec(input_file)
} }
def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_path,
stream_info, format_opt, transcode_audio, def get_metadata(input_file: str) -> Dict[str, any]:
drop_video, drop_subs, metadata=None): """
Retrieve metadata from the input file using ffprobe with JSON output.
Returns a dict with keys: album, title, comments (list of (stream_index, comment)).
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-show_entries', 'format_tags:stream_tags',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return {'album': None, 'title': None, 'comments': []}
try:
data = json.loads(result.stdout)
album = None
title = None
comments = []
fmt_tags = data.get('format', {}).get('tags', {})
album = fmt_tags.get('album') or album
title = fmt_tags.get('title') or title
for idx, stream in enumerate(data.get('streams', [])):
stream_tags = stream.get('tags', {})
if 'album' in stream_tags:
album = stream_tags['album']
if 'title' in stream_tags:
title = stream_tags['title']
if 'comment' in stream_tags:
comments.append((idx, stream_tags['comment']))
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', 'matroska') from the input file.
Args:
input_file: Path to the media file.
Returns:
Container format name (normalized) or None if detection fails.
"""
cmd = [
'ffprobe', '-v', 'error',
'-show_entries', 'format=format_name',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return None
format_name = result.stdout.strip().split(',')[0] # take first if multiple
if not format_name:
return None
# Normalize common aliases to names used in FORMAT_INFO (container only, not codec-specific)
# This mapping is purely for container identification.
mapping = {
'mpeg': 'mp3', # MPEG-1/2 audio (MP3) container
'mp2': 'mp3',
'mp4': 'mp4',
'm4a': 'mp4', # M4A is MP4 container
'mov': 'mp4', # QuickTime is MP4-like
'3gp': 'mp4',
'matroska': 'matroska',
'webm': 'matroska', # WebM uses Matroska container
'ogg': 'ogg',
'flac': 'flac',
'wav': 'wav',
'aac': 'aac',
'opus': 'opus',
'mp3': 'mp3',
'adts': 'aac', # raw AAC in ADTS container
'amr': 'amr', # AMR container (rare)
}
return mapping.get(format_name, format_name)
def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: int,
output_path: str, stream_info: Dict, format_opt: Optional[str],
transcode_audio: Optional[str], drop_video: bool, drop_subs: bool,
metadata: Optional[Dict] = None) -> List[str]:
""" """
Construct the FFmpeg command line as a list of arguments. Construct the FFmpeg command line as a list of arguments.
@@ -116,18 +208,17 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
'-t', format_time(duration_seconds) '-t', format_time(duration_seconds)
] ]
# -------------------- Clear all original metadata -------------------- # Clear all original metadata.
cmd.append('-map_metadata') cmd.append('-map_metadata')
cmd.append('-1') cmd.append('-1')
# -------------------- Apply custom metadata -------------------- # Apply custom metadata.
if metadata: if metadata:
for key, value in metadata.items(): for key, value in metadata.items():
if value is not None and value != '': if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"]) cmd.extend(['-metadata', f"{key}={value}"])
# -------------------- Stream mapping -------------------- # Stream mapping.
# Map the streams we want to keep.
if drop_video and drop_subs: if drop_video and drop_subs:
cmd.extend(['-map', '0:a:0']) cmd.extend(['-map', '0:a:0'])
elif drop_video: elif drop_video:
@@ -137,7 +228,7 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
else: else:
cmd.extend(['-map', '0']) cmd.extend(['-map', '0'])
# -------------------- Audio codec -------------------- # Audio codec.
if transcode_audio: if transcode_audio:
cmd.extend(['-c:a', transcode_audio]) cmd.extend(['-c:a', transcode_audio])
if transcode_audio in ('libmp3lame', 'mp3'): if transcode_audio in ('libmp3lame', 'mp3'):
@@ -147,77 +238,22 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
else: else:
cmd.extend(['-c:a', 'copy']) cmd.extend(['-c:a', 'copy'])
# -------------------- Video codec -------------------- # Video codec.
if not drop_video and stream_info['has_video']: if not drop_video and stream_info['has_video']:
cmd.extend(['-c:v', 'copy']) cmd.extend(['-c:v', 'copy'])
else: else:
cmd.append('-vn') cmd.append('-vn')
# -------------------- Subtitle codec -------------------- # Subtitle codec.
if not drop_subs and stream_info['has_subtitle']: if not drop_subs and stream_info['has_subtitle']:
cmd.extend(['-c:s', 'copy']) cmd.extend(['-c:s', 'copy'])
else: else:
cmd.append('-sn') cmd.append('-sn')
# -------------------- Output format -------------------- # Output format.
if format_opt: if format_opt:
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt) ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
cmd.extend(['-f', ffmpeg_format]) cmd.extend(['-f', ffmpeg_format])
# Overwrite output if it already exists.
cmd.extend(['-y', output_path]) cmd.extend(['-y', output_path])
return cmd return cmd
def get_metadata(input_file: str) -> dict:
"""
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
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-show_entries', 'format_tags:stream_tags',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return {'album': None, 'title': None, 'comments': []}
try:
data = json.loads(result.stdout)
# Collect album and title (merged, last wins).
album = None
title = None
comments = [] # list of (stream_index, comment)
# 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
}
except (json.JSONDecodeError, KeyError):
return {'album': None, 'title': None, 'comments': []}
+84 -9
View File
@@ -1,16 +1,79 @@
"""Container format decision and validation.""" """Container format decision and validation."""
from typing import Dict, Optional
from .constants import FORMAT_INFO from .constants import FORMAT_INFO
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.
This is used when the user has not explicitly specified a format.
It prioritizes the codec to choose the most appropriate container/extension.
Args:
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format().
codec: Audio codec name (e.g., 'opus', 'aac', 'mp3') as returned by get_audio_codec().
Returns:
Format name (e.g., 'opus', 'm4a', 'mp3') or None if unknown.
"""
if not container:
return None
# Codec-based decisions (highest priority)
if codec == 'opus':
return 'opus'
if codec in ('aac', 'alac', 'he-aac'):
return 'm4a'
if codec == 'mp3':
return 'mp3'
if codec == 'vorbis':
return 'ogg'
if codec == 'flac':
return 'flac'
# Container-based fallback (lower priority)
if container in ('mp4', 'm4a', 'mov', '3gp'):
return 'mp4'
if container in ('matroska', 'webm'):
return 'matroska'
if container in ('ogg',):
return 'ogg'
if container in ('mp3', 'mpeg'):
return 'mp3'
if container == 'flac':
return 'flac'
if container == 'wav':
return 'wav'
if container == 'aac':
return 'aac'
if container == 'opus':
return 'opus'
if container == 'amr':
return 'amr'
return None
def determine_output_format(stream_info: Dict, user_format: Optional[str],
transcode_audio: Optional[str], input_file: Optional[str] = None) -> str:
""" """
Decide which container format to use. Decide which container format to use.
If user_format is provided, use it.
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
Args: Args:
stream_info: Dict from get_stream_info(). stream_info: Dict from get_stream_info().
user_format: Userrequested format (or None). user_format: Userrequested format (or None).
transcode_audio: Audio codec to transcode to (or None). transcode_audio: Audio codec to transcode to (or None) (unused in this function).
input_file: Path to the input file (optional, used to detect container and codec).
Returns: Returns:
A format name that exists in FORMAT_INFO. A format name that exists in FORMAT_INFO.
@@ -18,11 +81,22 @@ def determine_output_format(stream_info, user_format, transcode_audio):
if user_format: if user_format:
return user_format return user_format
# If video or subtitles exist, use MKV (which supports everything). # If input_file is provided, try to detect container and codec
if stream_info['has_video'] or stream_info['has_subtitle']: if input_file:
return 'matroska' try:
from .ffmpeg import get_container_format, get_audio_codec
container = get_container_format(input_file)
codec = get_audio_codec(input_file)
fmt = determine_default_format(container, codec)
if fmt in FORMAT_INFO:
return fmt
except Exception:
# If detection fails, fall through to legacy logic
pass
# Audioonly: choose based on the current audio codec. # Fallback: legacy behavior
if stream_info.get('has_video') or stream_info.get('has_subtitle'):
return 'matroska'
audio_codec = stream_info.get('audio_codec', '') audio_codec = stream_info.get('audio_codec', '')
if audio_codec == 'mp3': if audio_codec == 'mp3':
return 'mp3' return 'mp3'
@@ -30,7 +104,8 @@ def determine_output_format(stream_info, user_format, transcode_audio):
return 'mp4' # .m4a return 'mp4' # .m4a
def validate_format_compatibility(format_name, stream_info, drop_video, drop_subs): def validate_format_compatibility(format_name: str, stream_info: Dict,
drop_video: bool, drop_subs: bool) -> None:
""" """
Ensure the chosen container can accommodate the streams we intend to keep. Ensure the chosen container can accommodate the streams we intend to keep.
@@ -43,12 +118,12 @@ def validate_format_compatibility(format_name, stream_info, drop_video, drop_sub
return return
if info['audio_only']: if info['audio_only']:
if stream_info['has_video'] and not drop_video: if stream_info.get('has_video') and not drop_video:
raise ValueError( raise ValueError(
f"Format '{format_name}' does not support video streams. " f"Format '{format_name}' does not support video streams. "
"Please use --drop-video or choose a container that supports video." "Please use --drop-video or choose a container that supports video."
) )
if stream_info['has_subtitle'] and not drop_subs: if stream_info.get('has_subtitle') and not drop_subs:
raise ValueError( raise ValueError(
f"Format '{format_name}' does not support subtitle streams. " f"Format '{format_name}' does not support subtitle streams. "
"Please use --drop-subs or choose a container that supports subtitles." "Please use --drop-subs or choose a container that supports subtitles."
+44
View File
@@ -0,0 +1,44 @@
# ------------------------------------------------------------------------------
# HTTPS Server Block (TLS Termination)
# ------------------------------------------------------------------------------
server {
listen 443 ssl;
server_name your.domain.tld;
ssl_certificate path;
ssl_certificate_key path;
# --------------------------------------------------------------------------
# Security Headers
# --------------------------------------------------------------------------
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# --------------------------------------------------------------------------
# Proxy to Frontend Container (everything goes through it)
# --------------------------------------------------------------------------
location / {
# Forward all traffic to the frontend container
proxy_pass http://127.0.0.1:5173; # <-- Frontend host port (adjust if needed)
proxy_http_version 1.1;
# Headers for correct client IP and protocol forwarding
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# The frontend container handles WebSocket upgrades internally,
# but we still need to pass the upgrade headers through.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Increase timeouts for long-running operations
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# Allow large file uploads (matches client_max_body_size in frontend NGINX)
client_max_body_size 500M;
}
}
+37
View File
@@ -0,0 +1,37 @@
services:
backend:
build:
context: .
dockerfile: web/backend/Dockerfile
volumes:
# Bind mount for source code (hot-reload for Python)
- ./web/backend:/app/backend
- ./audio_splitter:/app/audio_splitter
- ./setup.py:/app/setup.py
- ./pyproject.toml:/app/pyproject.toml
# Development data directory (overrides production)
- "./dev_data:/tmp/audio_splitter_web"
environment:
- DEBUG=1
- PYTHONUNBUFFERED=1
ports:
- "8000:8000"
frontend:
build:
context: ./web/frontend
# Use the same Dockerfile, but override CMD for development
dockerfile: Dockerfile
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
volumes:
# Bind mount for source code (hot-reload for Vite)
- ./web/frontend:/app
- node_modules:/app/node_modules
environment:
- BACKEND_URL=http://backend:8000
- VITE_BACKEND_URL=http://backend:8000
ports:
- "5173:5173"
volumes:
node_modules:
+21
View File
@@ -0,0 +1,21 @@
services:
backend:
image: git.vmn.su/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"
environment:
- PYTHONUNBUFFERED=1
- DEBUG=${DEBUG:-0}
restart: unless-stopped
frontend:
image: git.vmn.su/max/audio_splitter_frontend:latest
ports:
- "${FRONTEND_PORT:-5173}:80"
depends_on:
- backend
restart: unless-stopped
+18
View File
@@ -0,0 +1,18 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.venv
venv
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore
+4
View File
@@ -0,0 +1,4 @@
MAX_UPLOAD_SIZE_MB=500
TEMP_DIR=/tmp/audio_splitter_web
CLEANUP_AFTER_SECONDS=3600
ALLOW_ORIGINS=http://localhost:5173,http://localhost:3000
+45
View File
@@ -0,0 +1,45 @@
FROM python:3.13-slim
# Install FFmpeg, system dependencies, and gosu from APT
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
gosu \
&& \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set working directory
WORKDIR /app
# Copy requirements and install Python dependencies
COPY web/backend/requirements-web.txt .
RUN pip install --no-cache-dir -r requirements-web.txt
# Copy backend code
COPY web/backend /app/backend
# Copy and install audio_splitter package
COPY audio_splitter /app/audio_splitter
COPY setup.py pyproject.toml README.md /app/
RUN pip install --no-cache-dir /app
# Create a non-root user
RUN addgroup --system --gid 1000 appgroup && \
adduser --system --uid 1000 --ingroup appgroup appuser && \
chown -R appuser:appgroup /app
# Copy entrypoint script
COPY web/backend/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Set entrypoint
ENTRYPOINT ["/docker-entrypoint.sh"]
# Expose port
EXPOSE 8000
+8
View File
@@ -0,0 +1,8 @@
# From the web/ directory
cd web
# Install dependencies
pip install -r requirements-web.txt
# Run the server (note the module path: backend.main)
uvicorn backend.main:app --reload --port 8000
+1
View File
@@ -0,0 +1 @@
"""Audio Splitter Web Backend"""
+3
View File
@@ -0,0 +1,3 @@
"""API route handlers."""
from . import upload, split, split_file, status, download, formats, info
+90
View File
@@ -0,0 +1,90 @@
"""Download endpoint for split results."""
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from zipfile import ZipFile
import os
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.models.response import TaskStatus
router = APIRouter(prefix="/api", tags=["download"])
def _prepare_download(task_id: str):
"""Common logic to prepare and return the ZIP file."""
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["status"] != TaskStatus.DONE:
raise HTTPException(
status_code=400,
detail=f"Task {task_id} is not complete. Current status: {status['status']}"
)
output_dir = settings.temp_dir / task_id / "output"
if not output_dir.exists() or not any(output_dir.iterdir()):
raise HTTPException(status_code=404, detail="No output files found")
zip_path = settings.temp_dir / task_id / "splits.zip"
with ZipFile(zip_path, "w") as zipf:
for file_path in output_dir.iterdir():
if file_path.is_file():
zipf.write(file_path, arcname=file_path.name)
return zip_path
@router.get("/download/{task_id}")
async def download_results(task_id: str):
zip_path = _prepare_download(task_id)
return FileResponse(
path=zip_path,
media_type="application/zip",
filename=f"{task_id}_splits.zip",
headers={"Content-Disposition": f"attachment; filename={task_id}_splits.zip"}
)
@router.get("/download/{task_id}/splits.zip")
async def download_results_as_zip(task_id: str):
"""Alias endpoint that provides a .zip suffix for easier curl usage."""
zip_path = _prepare_download(task_id)
return FileResponse(
path=zip_path,
media_type="application/zip",
filename="splits.zip",
headers={"Content-Disposition": "attachment; filename=splits.zip"}
)
# NEW: Endpoint for downloading individual tracks
@router.get("/download/{task_id}/{filename}")
async def download_single_track(task_id: str, filename: str):
"""
Download a single track file from the output directory.
"""
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["status"] != TaskStatus.DONE:
raise HTTPException(
status_code=400,
detail=f"Task {task_id} is not complete. Current status: {status['status']}"
)
output_dir = settings.temp_dir / task_id / "output"
file_path = output_dir / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File '{filename}' not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/octet-stream",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
+25
View File
@@ -0,0 +1,25 @@
"""Endpoint to expose format information to the frontend."""
from fastapi import APIRouter
from backend.constants import FORMAT_INFO
router = APIRouter(prefix="/api", tags=["formats"])
@router.get("/formats")
async def get_formats():
"""
Return the list of supported container formats with their properties.
"""
return {
"formats": [
{
"name": name,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
}
+35
View File
@@ -0,0 +1,35 @@
"""Endpoint to retrieve stream information for an uploaded file."""
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
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.
"""
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:
info = get_stream_info(str(input_path))
return {
"task_id": task_id,
"has_audio": info["has_audio"],
"has_video": info["has_video"],
"has_subtitle": info["has_subtitle"],
"audio_codec": info["audio_codec"],
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
+39
View File
@@ -0,0 +1,39 @@
"""Split task endpoint."""
from fastapi import APIRouter, HTTPException, BackgroundTasks
from backend.models.request import SplitRequest
from backend.models.response import SplitResponse, TaskStatus
from backend.services.splitter import run_split_task
from backend.services.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["split"])
@router.post("/split", response_model=SplitResponse)
async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
task_id = request.task_id
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']}"
)
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Preparing to split..."
)
background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options)
return SplitResponse(
task_id=task_id,
status=TaskStatus.PROCESSING
)
+140
View File
@@ -0,0 +1,140 @@
"""Endpoint for splitting with a tracklist file upload."""
import json
from pathlib import Path
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"])
# Import CLI tracklist parsing functions
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(...),
options: str = Form("{}"),
tracklist_format: str = Form("%ts %tn - %an"),
):
"""
Start a splitting task using a tracklist file.
This endpoint accepts a plain text tracklist file (like the CLI does)
and parses it using the same logic.
Args:
task_id: Task ID from upload.
tracklist_file: Tracklist file (text/plain).
options: JSON string of all CLI options.
tracklist_format: Format string for parsing (default: "%ts %tn - %an").
Returns:
SplitResponse with task_id and status.
"""
# Validate task exists
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
# Check if task is already processing
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']}"
)
# Validate file type
if not tracklist_file.filename.endswith(('.txt', '.text')):
raise HTTPException(
status_code=400,
detail="Tracklist file must be a text file (.txt or .text)"
)
# Read and parse the tracklist file
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 the tracklist using CLI logic
try:
# Parse the format string into tokens
tokens = parse_format(tracklist_format)
# Write the content to a temporary file (read_tracklist expects a file path)
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')
# Parse the tracklist file using CLI logic
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 (same as JSON endpoint)
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)}"
)
# Parse options JSON
try:
options_dict = json.loads(options)
except json.JSONDecodeError:
raise HTTPException(
status_code=400,
detail="Invalid JSON in options field"
)
# Update task status
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Preparing to split..."
)
# Start background task
background_tasks.add_task(
run_split_task,
task_id,
tracklist_entries, # now TracklistEntry objects
options_dict
)
return SplitResponse(
task_id=task_id,
status=TaskStatus.PROCESSING
)
+25
View File
@@ -0,0 +1,25 @@
"""Status query endpoint."""
from fastapi import APIRouter, HTTPException
from backend.models.response import StatusResponse
from backend.services.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["status"])
@router.get("/status/{task_id}", response_model=StatusResponse)
async def get_status(task_id: str):
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)
return StatusResponse(
task_id=task_id,
status=status["status"],
progress=status["progress"],
message=status["message"],
error=status.get("error"),
tracks=status.get("tracks", [])
)
+58
View File
@@ -0,0 +1,58 @@
"""File upload endpoint."""
import uuid
import shutil
from pathlib import Path
from fastapi import APIRouter, UploadFile, File, HTTPException
from backend.config import settings
from backend.models.response import UploadResponse
from backend.services.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["upload"])
ALLOWED_EXTENSIONS = {
".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus",
".aac", ".wma", ".aiff", ".alac", ".ac3"
}
@router.post("/upload", response_model=UploadResponse)
async def upload_file(file: UploadFile = File(...)):
extension = Path(file.filename).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Unsupported file format. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
)
task_id = str(uuid.uuid4())[:8]
task_dir = settings.temp_dir / task_id
task_dir.mkdir(parents=True, exist_ok=True)
input_path = task_dir / f"input{extension}"
try:
with open(input_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
shutil.rmtree(task_dir, ignore_errors=True)
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
file_size = input_path.stat().st_size
max_size_bytes = settings.max_upload_size_mb * 1024 * 1024
if file_size > max_size_bytes:
shutil.rmtree(task_dir, ignore_errors=True)
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size: {settings.max_upload_size_mb} MB"
)
# Create task entry in the task manager
task_manager.create_task(task_id, file.filename, file_size)
return UploadResponse(
task_id=task_id,
filename=file.filename,
size=file_size
)
+46
View File
@@ -0,0 +1,46 @@
"""WebSocket endpoint for realtime progress updates."""
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from backend.services.task_manager import task_manager
from backend.services.progress_publisher import register_connection, unregister_connection
router = APIRouter(tags=["websocket"])
@router.websocket("/ws/{task_id}")
async def websocket_endpoint(websocket: WebSocket, task_id: str):
await websocket.accept()
register_connection(task_id, websocket)
try:
# Send initial status
status = task_manager.get_status(task_id)
await websocket.send_json({
"type": "status",
"data": status
})
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("type") == "ping":
await websocket.send_json({"type": "pong"})
elif message.get("type") == "get_status":
status = task_manager.get_status(task_id)
await websocket.send_json({
"type": "status",
"data": status
})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
unregister_connection(task_id, websocket)
except Exception as e:
unregister_connection(task_id, websocket)
print(f"WebSocket error: {e}")
+33
View File
@@ -0,0 +1,33 @@
"""Configuration settings for the web backend."""
import os
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Server
host: str = "0.0.0.0"
port: int = 8000
debug: bool = False
# File handling
max_upload_size_mb: int = 500
temp_dir: Path = Path("/tmp/audio_splitter_web")
cleanup_after_seconds: int = 3600 # 1 hour
# CORS
allow_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
# Ensure temp directory exists
settings.temp_dir.mkdir(parents=True, exist_ok=True)
+12
View File
@@ -0,0 +1,12 @@
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},
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -e
# Detect if we are running as root (default)
if [ "$(id -u)" = "0" ]; then
# Ensure the temp directory exists and set ownership
if [ -d "/tmp/audio_splitter_web" ]; then
echo "Setting ownership of /tmp/audio_splitter_web to appuser:appgroup"
chown -R appuser:appgroup /tmp/audio_splitter_web
else
echo "Creating /tmp/audio_splitter_web and setting ownership"
mkdir -p /tmp/audio_splitter_web
chown -R appuser:appgroup /tmp/audio_splitter_web
fi
# Drop privileges and run uvicorn using gosu
exec gosu appuser uvicorn backend.main:app --host 0.0.0.0 --port 8000
else
# If not root, just run directly
exec uvicorn backend.main:app --host 0.0.0.0 --port 8000
fi
+79
View File
@@ -0,0 +1,79 @@
"""FFmpeg/FFprobe interaction utilities for the web backend."""
import subprocess
import json
def get_stream_info(input_file: str):
"""
Retrieve stream information (audio, video, subtitle presence) from a media file.
Returns a dict with keys: has_audio, has_video, has_subtitle, audio_codec.
"""
# Get audio codec (if any)
audio_codec = None
try:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'a: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()
if codec:
audio_codec = codec
except Exception:
pass
# Check for video stream
has_video = False
try:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'v',
'-show_entries', 'stream=codec_type',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
has_video = bool(result.stdout.strip())
except Exception:
pass
# Check for subtitle stream
has_subtitle = False
try:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 's',
'-show_entries', 'stream=codec_type',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
has_subtitle = bool(result.stdout.strip())
except Exception:
pass
return {
'has_audio': audio_codec is not None,
'has_video': has_video,
'has_subtitle': has_subtitle,
'audio_codec': audio_codec,
}
def get_audio_duration(input_file: str) -> float:
"""Get the duration of the audio file in seconds."""
cmd = [
'ffprobe', '-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
try:
return float(result.stdout.strip())
except ValueError:
return 0.0
+51
View File
@@ -0,0 +1,51 @@
"""FastAPI application entry point."""
import asyncio
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.config import settings
from backend.api import upload, split, split_file, status, download, websocket, formats, info
from backend.services import progress_publisher
app = FastAPI(
title="Audio Splitter Web API",
description="Web interface for splitting audio files using a tracklist",
version="0.1.0",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup_event():
"""Store the main event loop for use in other threads."""
progress_publisher.MAIN_LOOP = asyncio.get_running_loop()
# 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)
app.include_router(info.router)
@app.get("/")
async def root():
return {"status": "ok", "service": "Audio Splitter Web API"}
@app.get("/health")
async def health():
return {"status": "healthy"}
+1
View File
@@ -0,0 +1 @@
"""Pydantic models for request/response validation."""
+58
View File
@@ -0,0 +1,58 @@
"""Request models for API endpoints."""
from typing import Optional, List
from pydantic import BaseModel, Field, validator
class TracklistEntry(BaseModel):
"""A single track entry from the tracklist."""
ts: str = Field(..., description="Timestamp (e.g., '00:00' or '00:00-01:30')")
tn: Optional[str] = Field("", description="Track name")
an: Optional[str] = Field("", description="Author/artist")
al: Optional[str] = Field("", description="Album")
date: Optional[str] = Field("", description="Date/year")
ext: Optional[str] = Field("", description="File extension")
@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:
cls._validate_single_timestamp(v)
return v
@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")
try:
for p in parts:
int(p)
except ValueError:
raise ValueError(f"Invalid timestamp: {ts}. Must contain only numbers.")
class SplitRequest(BaseModel):
"""Request model for the split endpoint."""
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")
+38
View File
@@ -0,0 +1,38 @@
"""Response models for API endpoints."""
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field
class TaskStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
DONE = "done"
ERROR = "error"
class TrackInfo(BaseModel):
filename: str
size: int # bytes
class UploadResponse(BaseModel):
task_id: str
filename: str
size: int
class SplitResponse(BaseModel):
task_id: str
status: TaskStatus
class StatusResponse(BaseModel):
task_id: str
status: TaskStatus
progress: int = Field(0, ge=0, le=100)
message: str = ""
error: Optional[str] = None
tracks: List[TrackInfo] = []
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
python-multipart>=0.0.6
aiofiles>=23.2.0
pydantic>=2.5.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
websockets>=12.0
+1
View File
@@ -0,0 +1 @@
"""Business logic services."""
+47
View File
@@ -0,0 +1,47 @@
"""Temporary file management for web operations."""
import shutil
from pathlib import Path
from backend.config import settings
class FileManager:
@staticmethod
def get_task_dir(task_id: str) -> Path:
return settings.temp_dir / task_id
@staticmethod
def get_input_path(task_id: str) -> Path:
task_dir = FileManager.get_task_dir(task_id)
for f in task_dir.iterdir():
if f.name.startswith("input."):
return f
return None
@staticmethod
def get_output_dir(task_id: str) -> Path:
return FileManager.get_task_dir(task_id) / "output"
@staticmethod
def ensure_output_dir(task_id: str) -> Path:
output_dir = FileManager.get_output_dir(task_id)
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
@staticmethod
def cleanup_task(task_id: str) -> None:
task_dir = FileManager.get_task_dir(task_id)
if task_dir.exists():
shutil.rmtree(task_dir, ignore_errors=True)
@staticmethod
def get_output_files(task_id: str) -> list:
output_dir = FileManager.get_output_dir(task_id)
if not output_dir.exists():
return []
files = []
for f in output_dir.iterdir():
if f.is_file():
files.append({"filename": f.name, "size": f.stat().st_size})
return files
@@ -0,0 +1,66 @@
"""WebSocket progress publisher decouples task manager from WebSocket."""
import asyncio
from typing import Dict, Set
from fastapi import WebSocket
# This will be set by main.py during startup
MAIN_LOOP = None
active_connections: Dict[str, Set[WebSocket]] = {}
def publish_progress(task_id: str, progress: int, message: str, status: str = "processing"):
"""
Publish progress update to all connected WebSocket clients for a task.
"""
if task_id not in active_connections:
return
data = {
"type": "progress",
"data": {
"task_id": task_id,
"status": status,
"progress": progress,
"message": message
}
}
to_remove = set()
# Get the loop to use: either the stored one or try to get the current loop
loop = MAIN_LOOP
if loop is None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop, fallback to default event loop
loop = asyncio.get_event_loop()
for websocket in active_connections.get(task_id, set()):
try:
asyncio.run_coroutine_threadsafe(websocket.send_json(data), loop)
except Exception:
to_remove.add(websocket)
# Clean up disconnected clients
for websocket in to_remove:
active_connections[task_id].discard(websocket)
if task_id in active_connections and not active_connections[task_id]:
del active_connections[task_id]
def register_connection(task_id: str, websocket: WebSocket):
"""Register a WebSocket connection for a task."""
if task_id not in active_connections:
active_connections[task_id] = set()
active_connections[task_id].add(websocket)
def unregister_connection(task_id: str, websocket: WebSocket):
"""Unregister a WebSocket connection for a task."""
if task_id in active_connections:
active_connections[task_id].discard(websocket)
if not active_connections[task_id]:
del active_connections[task_id]
+102
View File
@@ -0,0 +1,102 @@
"""Splitter service that calls the core audio_splitter logic."""
import os
import sys
import shutil
from pathlib import Path
from typing import List, Dict, Any
import time
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.services.file_manager import FileManager
from backend.models.request import TracklistEntry
from backend.models.response import TaskStatus
def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None:
try:
task_manager.update_task_with_progress(
task_id, progress=5, message="Initializing..."
)
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
raise RuntimeError(f"Input file not found for task {task_id}")
output_dir = FileManager.ensure_output_dir(task_id)
tracks = []
for entry in tracklist:
track_dict = {
"ts": entry.ts,
"tn": entry.tn or "",
"an": entry.an or "",
"al": entry.al or "",
"date": entry.date or "",
"ext": entry.ext or ""
}
tracks.append(track_dict)
from types import SimpleNamespace
# Build args namespace from options.
# IMPORTANT: format is None if not specified -> core will auto-detect.
args = SimpleNamespace(
format=options.get("format"),
transcode_to=options.get("transcode_to", None),
drop_video=options.get("drop_video", False),
drop_subs=options.get("drop_subs", False),
number_tracks=options.get("number_tracks", False),
replace_bad_chars=options.get("replace_bad_chars", False),
replacement_char=options.get("replacement_char", "_"),
bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
skip_existing=options.get("skip_existing", False),
output_template=options.get("output_template", "%an-%tn.%ext"),
album=options.get("album", None),
comment=options.get("comment", None),
no_comment=options.get("no_comment", False),
comment_stream=options.get("comment_stream", None),
merge_comments=options.get("merge_comments", False),
comment_separator=options.get("comment_separator", "; "),
delete_original=False,
)
project_root = Path(__file__).parent.parent.parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from audio_splitter.core import split_audio
task_manager.update_task_with_progress(
task_id, progress=10, message="Starting split..."
)
split_audio(str(input_path), str(output_dir), tracks, args)
output_files = FileManager.get_output_files(task_id)
task_manager.update_task_with_progress(
task_id,
progress=100,
message="Split complete",
status=TaskStatus.DONE
)
# Add tracks to the task state (for download and status queries)
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:
task_manager.update_task_with_progress(
task_id,
progress=0,
message="Split failed",
status=TaskStatus.ERROR,
error=str(e)
)
+110
View File
@@ -0,0 +1,110 @@
"""In-memory task state management."""
from typing import Dict, Optional, List
from datetime import datetime, timedelta
import threading
import time
from backend.config import settings
from backend.models.response import TaskStatus, TrackInfo
from backend.services.progress_publisher import publish_progress
class TaskManager:
def __init__(self):
self._tasks: Dict[str, dict] = {}
self._lock = threading.Lock()
def create_task(self, task_id: str, filename: str, file_size: int) -> None:
with self._lock:
self._tasks[task_id] = {
"task_id": task_id,
"filename": filename,
"file_size": file_size,
"status": TaskStatus.PENDING,
"progress": 0,
"message": "Upload complete",
"error": None,
"tracks": [],
"created_at": datetime.now(),
"updated_at": datetime.now()
}
def has_task(self, task_id: str) -> bool:
return task_id in self._tasks
def get_status(self, task_id: str) -> dict:
"""Get task status with datetime objects converted to ISO format."""
with self._lock:
if task_id not in self._tasks:
return {}
# Return a copy with datetime objects converted to ISO strings
status = self._tasks[task_id].copy()
return self._prepare_status_for_serialization(status)
def _prepare_status_for_serialization(self, status: dict) -> dict:
"""Convert datetime objects to ISO format strings for JSON serialization."""
serializable = {}
for key, value in status.items():
if isinstance(value, datetime):
serializable[key] = value.isoformat()
elif isinstance(value, list):
# Handle lists of objects (e.g., tracks)
serializable[key] = [
{k: v.isoformat() if isinstance(v, datetime) else v for k, v in item.items()}
if isinstance(item, dict)
else item
for item in value
]
elif isinstance(value, dict):
# Recursively handle nested dicts
serializable[key] = {
k: v.isoformat() if isinstance(v, datetime) else v
for k, v in value.items()
}
else:
serializable[key] = value
return serializable
def update_task(self, task_id: str, **kwargs) -> None:
with self._lock:
if task_id in self._tasks:
self._tasks[task_id].update(kwargs)
self._tasks[task_id]["updated_at"] = datetime.now()
def update_task_with_progress(self, task_id: str, progress: int, message: str,
status: str = None, error: str = None) -> None:
update_kwargs = {
"progress": progress,
"message": message
}
if status:
update_kwargs["status"] = status
if error is not None:
update_kwargs["error"] = error
self.update_task(task_id, **update_kwargs)
# Publish progress via WebSocket
publish_progress(task_id, progress, message, status or "processing")
def cleanup_old_tasks(self) -> None:
now = datetime.now()
timeout = timedelta(seconds=settings.cleanup_after_seconds)
with self._lock:
to_delete = []
for task_id, data in self._tasks.items():
if now - data["created_at"] > timeout:
to_delete.append(task_id)
for task_id in to_delete:
self._delete_task_files(task_id)
del self._tasks[task_id]
def _delete_task_files(self, task_id: str) -> None:
import shutil
task_dir = settings.temp_dir / task_id
if task_dir.exists():
shutil.rmtree(task_dir, ignore_errors=True)
task_manager = TaskManager()
+1
View File
@@ -0,0 +1 @@
"""Utility functions for the web backend."""
+23
View File
@@ -0,0 +1,23 @@
"""Input validation utilities."""
import re
from typing import List, Tuple
from ..models.request import TracklistEntry
def validate_tracklist(tracklist: List[TracklistEntry]) -> Tuple[bool, List[str]]:
"""
Validate a tracklist and return any errors.
Returns:
(is_valid, error_messages)
"""
errors = []
for idx, entry in enumerate(tracklist, 1):
# Each entry must have a timestamp
if not entry.ts or not entry.ts.strip():
errors.append(f"Line {idx}: Missing timestamp (%ts)")
# Additional validation could be added here
return len(errors) == 0, errors
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
*.log
.env
.env.local
.DS_Store
Dockerfile
Dockerfile.dev
.dockerignore
nginx.conf
+27
View File
@@ -0,0 +1,27 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm install
# Copy the application code and build
COPY . .
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 nginx configuration
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Expose the port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Audio Splitter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
client_max_body_size 500M;
# Root directory for static files
root /usr/share/nginx/html;
index index.html;
# Serve React app
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy WebSocket requests to backend
location /ws/ {
proxy_pass http://backend:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+4239
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "audio-splitter-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@mui/x-data-grid": "^6.18.5",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}
+127
View File
@@ -0,0 +1,127 @@
import React from 'react'
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material'
import { Box, Grid, Button, CircularProgress, Typography } from '@mui/material'
import { PlayArrow } from '@mui/icons-material'
import { Layout } from './components/Layout'
import { UploadZone } from './components/UploadZone'
import { TracklistEditor } from './components/TracklistEditor'
import { OptionsPanel } from './components/OptionsPanel'
import { ProgressDisplay } from './components/ProgressDisplay'
import { DownloadSection } from './components/DownloadSection'
import { useUploadStore } from './stores/uploadStore'
import { useTracklistStore } from './stores/tracklistStore'
import { useOptionsStore } from './stores/optionsStore'
import { useTaskStore } from './stores/taskStore'
import { useUIStore } from './stores/uiStore'
import { useValidationStore } from './stores/validationStore'
import { useWebSocket } from './hooks/useWebSocket'
import { startSplit } from './api/client'
const App: React.FC = () => {
const { theme } = useUIStore()
const { taskId } = useUploadStore()
const { entries, isValid } = useTracklistStore()
const { options } = useOptionsStore()
const {
isProcessing,
setTaskId,
setError,
setIsProcessing,
addLog,
reset,
} = useTaskStore()
const { formatError } = useValidationStore()
useWebSocket(taskId && isProcessing ? taskId : null)
const handleSplit = async () => {
if (!taskId) {
alert('Please upload a file first')
return
}
if (!isValid) {
alert('Tracklist has errors. Please fix them before splitting.')
return
}
if (entries.length === 0) {
alert('Tracklist is empty')
return
}
try {
setTaskId(taskId)
setIsProcessing(true)
addLog('🚀 Starting split...')
const response = await startSplit(taskId, entries, options)
addLog(`✅ Split task started (ID: ${response.task_id})`)
} catch (error: any) {
setError(error.response?.data?.detail || error.message || 'Failed to start split')
addLog(`❌ Error: ${error.response?.data?.detail || error.message || 'Failed to start split'}`)
setIsProcessing(false)
}
}
const handleReset = () => {
reset()
}
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError
return (
<ThemeProvider
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<CssBaseline />
<Layout>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<UploadZone />
</Grid>
<Grid item xs={12} md={6}>
<TracklistEditor />
</Grid>
<Grid item xs={12} md={4}>
<OptionsPanel />
</Grid>
<Grid item xs={12} md={8}>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<Button
variant="contained"
color="success"
startIcon={isProcessing ? <CircularProgress size={20} color="inherit" /> : <PlayArrow />}
onClick={handleSplit}
disabled={isSplitDisabled}
sx={{ flex: 1 }}
>
{isProcessing ? 'Processing...' : 'Split'}
</Button>
<Button variant="outlined" color="secondary" onClick={handleReset} disabled={isProcessing}>
Reset
</Button>
</Box>
{formatError && (
<Box sx={{ mb: 2, p: 2, bgcolor: 'warning.light', borderRadius: 1 }}>
<Typography color="warning.dark" variant="body2">
{formatError}
</Typography>
</Box>
)}
<ProgressDisplay />
<DownloadSection />
</Grid>
</Grid>
</Layout>
</ThemeProvider>
)
}
export default App
+65
View File
@@ -0,0 +1,65 @@
import axios from 'axios'
import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
export const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
export const startSplit = async (
task_id: string,
tracklist: TracklistEntry[],
options: SplitOptions
): Promise<{ task_id: string; status: string }> => {
const response = await api.post('/split', {
task_id,
tracklist,
options,
})
return response.data
}
export const getStatus = async (task_id: string): Promise<TaskStatus> => {
const response = await api.get(`/status/${task_id}`)
return response.data
}
export const getDownloadUrl = (task_id: string): string => {
return `/api/download/${task_id}`
}
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 }> }> => {
const response = await api.get('/formats')
return response.data
}
export const getTaskInfo = async (task_id: string): Promise<{
task_id: string
has_audio: boolean
has_video: boolean
has_subtitle: boolean
audio_codec: string | null
}> => {
const response = await api.get(`/info/${task_id}`)
return response.data
}
@@ -0,0 +1,73 @@
import React from 'react'
import { Paper, Typography, Button, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material'
import { Download, FolderZip } from '@mui/icons-material'
import { useTaskStore } from '../stores/taskStore'
import { getDownloadZipUrl } from '../api/client'
import { formatFileSize } from '../utils/formatters'
export const DownloadSection: React.FC = () => {
const { taskId, tracks, status } = useTaskStore()
console.log('[DownloadSection] Rendering:', { status, tracks, taskId })
// Check if we should show the download section
if (status !== 'done' || !tracks || tracks.length === 0 || !taskId) {
return null
}
// If we get here, we have tracks
console.log('[DownloadSection] Showing tracks:', tracks)
const handleDownloadZip = () => {
const url = getDownloadZipUrl(taskId)
window.open(url, '_blank')
}
const handleDownloadTrack = (filename: string) => {
const url = `/api/download/${taskId}/${filename}`
window.open(url, '_blank')
}
return (
<Paper sx={{ p: 3, mt: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
📥 Download Results
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<FolderZip />}
onClick={handleDownloadZip}
sx={{ mb: 2 }}
fullWidth
>
Download All as ZIP
</Button>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Individual Tracks
</Typography>
<List dense>
{tracks.map((track, index) => (
<ListItem
key={index}
secondaryAction={
<IconButton edge="end" onClick={() => handleDownloadTrack(track.filename)} size="small">
<Download />
</IconButton>
}
>
<ListItemText
primary={track.filename}
secondary={formatFileSize(track.size)}
/>
</ListItem>
))}
</List>
</Paper>
)
}
+57
View File
@@ -0,0 +1,57 @@
import React from 'react'
import { AppBar, Toolbar, Typography, IconButton, Box, Container, Badge } from '@mui/material'
import { Brightness4, Brightness7, FiberManualRecord } from '@mui/icons-material'
import { useUIStore } from '../stores/uiStore'
interface LayoutProps {
children: React.ReactNode
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const { theme, toggleTheme, wsConnected } = useUIStore()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static" color="default" elevation={1}>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
🎵 Audio Splitter
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Badge
color={wsConnected ? 'success' : 'error'}
variant="dot"
sx={{ mr: 1 }}
>
<FiberManualRecord
sx={{
fontSize: 12,
color: wsConnected ? 'green' : 'red',
visibility: 'hidden',
}}
/>
</Badge>
<Typography variant="caption" color="text.secondary">
{wsConnected ? 'Connected' : 'Disconnected'}
</Typography>
<IconButton onClick={toggleTheme} color="inherit">
{theme === 'light' ? <Brightness4 /> : <Brightness7 />}
</IconButton>
</Box>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ flex: 1, py: 4 }}>
{children}
</Container>
<Box component="footer" sx={{ py: 2, textAlign: 'center', borderTop: 1, borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary">
Audio Splitter v0.1.0 Built with
</Typography>
</Box>
</Box>
)
}
@@ -0,0 +1,307 @@
import React, { useEffect } from 'react'
import {
Box,
Paper,
Typography,
TextField,
MenuItem,
FormControlLabel,
Switch,
Collapse,
IconButton,
Divider,
Alert,
} from '@mui/material'
import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore'
interface SectionProps {
title: string
children: React.ReactNode
defaultExpanded?: boolean
}
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded)
return (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
py: 1,
}}
onClick={() => setExpanded(!expanded)}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{title}
</Typography>
<IconButton size="small">{expanded ? <ExpandLess /> : <ExpandMore />}</IconButton>
</Box>
<Divider />
<Collapse in={expanded}>
<Box sx={{ pt: 2, pb: 1 }}>{children}</Box>
</Collapse>
</Box>
)
}
export const OptionsPanel: React.FC = () => {
const { options, setOptions } = 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)
useEffect(() => {
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
const newError = shouldShowError
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
: null
// Only update if the error state actually changes
if (newError !== formatError) {
setFormatError(newError)
}
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
return (
<Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Options
</Typography>
{formatError && (
<Alert severity="warning" sx={{ mb: 2 }}>
{formatError}
</Alert>
)}
{/* Output Settings */}
<Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Format"
select
value={options.format}
onChange={handleFormatChange}
fullWidth
size="small"
error={!!formatError}
>
<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>
</TextField>
<TextField
label="Transcode to"
select
value={options.transcode_to || ''}
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
fullWidth
size="small"
>
<MenuItem value="">Copy (no transcoding)</MenuItem>
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
<MenuItem value="libopus">OPUS</MenuItem>
</TextField>
{hasVideo && (
<FormControlLabel
control={
<Switch
checked={options.drop_video}
onChange={handleDropVideoChange}
/>
}
label="Drop video streams"
/>
)}
<FormControlLabel
control={
<Switch
checked={options.drop_subs}
onChange={(e) => handleChange('drop_subs', e.target.checked)}
/>
}
label="Drop subtitle streams"
/>
</Box>
</Section>
{/* Filename Settings */}
<Section title="Filename Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Output template"
value={options.output_template}
onChange={(e) => handleChange('output_template', e.target.value)}
fullWidth
size="small"
helperText="Placeholders: %tn (track name), %an (author), %al (album), %date, %ext, %num"
/>
<FormControlLabel
control={
<Switch
checked={options.number_tracks}
onChange={(e) => handleChange('number_tracks', e.target.checked)}
/>
}
label="Number tracks (01 - )"
/>
<FormControlLabel
control={
<Switch
checked={options.replace_bad_chars}
onChange={(e) => handleChange('replace_bad_chars', e.target.checked)}
/>
}
label="Replace bad characters"
/>
<TextField
label="Replacement character"
value={options.replacement_char}
onChange={(e) => handleChange('replacement_char', e.target.value)}
size="small"
disabled={!options.replace_bad_chars}
/>
<TextField
label="Bad characters list"
value={options.bad_chars}
onChange={(e) => handleChange('bad_chars', e.target.value)}
fullWidth
size="small"
disabled={!options.replace_bad_chars}
/>
<FormControlLabel
control={
<Switch
checked={options.skip_existing}
onChange={(e) => handleChange('skip_existing', e.target.checked)}
/>
}
label="Skip existing files"
/>
</Box>
</Section>
{/* Metadata Settings */}
<Section title="Metadata Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Album"
value={options.album}
onChange={(e) => handleChange('album', e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Comment"
value={options.comment}
onChange={(e) => handleChange('comment', e.target.value)}
fullWidth
size="small"
/>
<FormControlLabel
control={
<Switch
checked={options.no_comment}
onChange={(e) => handleChange('no_comment', e.target.checked)}
/>
}
label="No comment"
/>
<TextField
label="Comment stream index"
type="number"
value={options.comment_stream ?? ''}
onChange={(e) =>
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
}
size="small"
disabled={options.no_comment}
/>
<FormControlLabel
control={
<Switch
checked={options.merge_comments}
onChange={(e) => handleChange('merge_comments', e.target.checked)}
/>
}
label="Merge all comments"
disabled={options.no_comment}
/>
<TextField
label="Comment separator"
value={options.comment_separator}
onChange={(e) => handleChange('comment_separator', e.target.value)}
size="small"
disabled={!options.merge_comments || options.no_comment}
/>
</Box>
</Section>
{/* Tracklist Settings */}
<Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Tracklist Format"
value={options.tracklist_format}
onChange={(e) => handleChange('tracklist_format', e.target.value)}
fullWidth
size="small"
helperText="Placeholders: %ts (timestamp), %tn (track name), %an (author), %al (album), %date, %ext"
/>
</Box>
</Section>
</Paper>
)
// Helper function for option updates
function handleChange(field: string, value: any) {
setOptions({ [field]: value })
}
}
@@ -0,0 +1,107 @@
import React, { useEffect, useRef } from 'react'
import { Box, Paper, Typography, LinearProgress, Alert, Chip } from '@mui/material'
import { useTaskStore } from '../stores/taskStore'
export const ProgressDisplay: React.FC = () => {
const { status, progress, message, error, tracks, logs } = useTaskStore()
const logContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, [logs])
if (!status) {
return null
}
const getStatusColor = () => {
switch (status) {
case 'pending':
return 'info'
case 'processing':
return 'warning'
case 'done':
return 'success'
case 'error':
return 'error'
default:
return 'default'
}
}
const getStatusLabel = () => {
switch (status) {
case 'pending':
return 'Waiting'
case 'processing':
return 'Processing'
case 'done':
return 'Complete'
case 'error':
return 'Error'
default:
return 'Unknown'
}
}
return (
<Paper sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">📊 Progress</Typography>
<Chip label={getStatusLabel()} color={getStatusColor()} size="small" />
</Box>
<Box sx={{ mb: 2 }}>
<LinearProgress
variant="determinate"
value={progress}
color={status === 'error' ? 'error' : status === 'done' ? 'success' : 'primary'}
sx={{ height: 10, borderRadius: 5 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{progress}% {message}
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{tracks.length > 0 && status === 'done' && (
<Alert severity="success" sx={{ mb: 2 }}>
{tracks.length} track(s) extracted successfully!
</Alert>
)}
<Box
ref={logContainerRef}
sx={{
maxHeight: 200,
overflowY: 'auto',
bgcolor: 'background.default',
p: 2,
borderRadius: 1,
fontFamily: 'monospace',
fontSize: '12px',
lineHeight: 1.6,
}}
>
{logs.length === 0 ? (
<Typography variant="caption" color="text.secondary">
Waiting for progress updates...
</Typography>
) : (
logs.map((log, index) => (
<div key={index} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{log}
</div>
))
)}
</Box>
</Paper>
)
}
@@ -0,0 +1,138 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore'
import { useOptionsStore } from '../stores/optionsStore'
import { parseAndValidateTracklist } from '../utils/validators'
export const TracklistEditor: React.FC = () => {
const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false)
const validate = (text: string) => {
const result = parseAndValidateTracklist(text, options.tracklist_format)
setEntries(result.entries)
setErrors(result.errors)
setIsValid(result.isValid)
}
const handleTextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = event.target.value
setRawText(text)
validate(text)
}
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
const file = acceptedFiles[0]
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result as string
setRawText(text)
validate(text)
setIsDragging(false)
}
reader.readAsText(file)
},
[setRawText]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'text/plain': ['.txt'],
},
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 (
<Paper
{...getRootProps()}
sx={{
p: 3,
border: isDragActive ? '2px dashed' : '1px solid',
borderColor: isDragActive ? 'primary.main' : 'divider',
borderRadius: 2,
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
transition: 'all 0.2s ease',
}}
>
<input {...getInputProps()} />
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Tracklist
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
{lineCount} track(s)
{isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
Format: {options.tracklist_format}
</Typography>
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
{/* Line numbers column */}
<Box
sx={{
minWidth: 40,
maxWidth: 40,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: 1.7,
color: 'text.secondary',
textAlign: 'right',
userSelect: 'none',
overflow: 'hidden',
}}
>
{rawText.split('\n').map((_, i) => (
<div key={i}>{i + 1}</div>
))}
</Box>
{/* Editor text area */}
<TextField
multiline
fullWidth
minRows={10}
maxRows={20}
value={rawText}
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"
sx={{
'& .MuiInputBase-root': {
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: 1.7,
},
}}
error={!isValid && errors.length > 0}
helperText={
!isValid && errors.length > 0
? errors.map((e) => `Line ${e.line}: ${e.message}`).join('; ')
: 'Drop a .txt file here or paste your tracklist'
}
/>
</Box>
{isDragActive && (
<Alert severity="info" sx={{ mt: 2 }}>
Drop your tracklist file (.txt) here
</Alert>
)}
</Paper>
)
}
+160
View File
@@ -0,0 +1,160 @@
import React, { useCallback } from 'react'
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 { useTaskStore } from '../stores/taskStore'
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
export const UploadZone: React.FC = () => {
const {
file,
fileName,
fileSize,
isUploading,
uploadProgress,
error,
setFile,
setFileName,
setFileSize,
setIsUploading,
setUploadProgress,
setError,
setTaskId,
setHasVideo,
setHasAudio,
setHasSubtitle,
setAudioCodec,
} = useUploadStore()
const { setTaskId: setTaskIdStore } = useTaskStore()
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
const selectedFile = acceptedFiles[0]
const extension = '.' + selectedFile.name.split('.').pop()?.toLowerCase()
if (!ALLOWED_EXTENSIONS.includes(extension)) {
setError(`Unsupported file format. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`)
return
}
setFile(selectedFile)
setFileName(selectedFile.name)
setFileSize(selectedFile.size)
setError(null)
setIsUploading(true)
setUploadProgress(0)
try {
const response = await uploadFile(selectedFile)
const taskId = response.task_id
setTaskId(taskId)
setTaskIdStore(taskId)
setUploadProgress(100)
setIsUploading(false)
// Fetch stream info
try {
const info = await getTaskInfo(taskId)
setHasVideo(info.has_video)
setHasAudio(info.has_audio)
setHasSubtitle(info.has_subtitle)
setAudioCodec(info.audio_codec)
} 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
}
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Upload failed')
setIsUploading(false)
setUploadProgress(0)
setFile(null)
setFileName('')
setFileSize(0)
}
},
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'audio/*': ALLOWED_EXTENSIONS,
},
multiple: false,
disabled: isUploading,
})
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return (
<Box>
<Paper
{...getRootProps()}
sx={{
p: 4,
border: '2px dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.300',
borderRadius: 2,
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
cursor: isUploading ? 'default' : 'pointer',
transition: 'all 0.2s ease',
textAlign: 'center',
}}
>
<input {...getInputProps()} />
{file ? (
<Box>
<InsertDriveFile sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
<Typography variant="h6">{fileName}</Typography>
<Typography variant="body2" color="text.secondary">
{formatFileSize(fileSize)}
</Typography>
{isUploading && (
<Box sx={{ mt: 2, width: '100%' }}>
<LinearProgress variant="determinate" value={uploadProgress} />
<Typography variant="caption" color="text.secondary">
{uploadProgress}% uploaded
</Typography>
</Box>
)}
{!isUploading && (
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
Uploaded successfully
</Typography>
)}
</Box>
) : (
<Box>
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
{isDragActive ? 'Drop your audio file here' : 'Drag & drop your audio file here'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
or click to browse
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
Supported formats: {ALLOWED_EXTENSIONS.join(', ')}
</Typography>
</Box>
)}
</Paper>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</Box>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useRef } from 'react'
import { useTaskStore } from '../stores/taskStore'
import { useUIStore } from '../stores/uiStore'
import { getStatus } from '../api/client'
export const useWebSocket = (taskId: string | null) => {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reconnectAttempts = useRef(0)
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const {
setStatus,
setProgress,
setMessage,
setError,
setTracks,
addLog,
setIsProcessing,
status,
} = useTaskStore()
const { setWsConnected } = useUIStore()
const pollStatus = async () => {
if (!taskId) return
try {
const response = await getStatus(taskId)
console.log('[Polling] Status response:', response)
// Update all state fields together
setStatus(response.status)
setProgress(response.progress)
setMessage(response.message)
// Explicitly set tracks if present
if (response.tracks && response.tracks.length > 0) {
console.log('[Polling] Setting tracks:', response.tracks)
setTracks(response.tracks)
}
if (response.status === 'done') {
console.log('[Polling] Split complete, tracks set:', response.tracks)
setIsProcessing(false)
// Ensure tracks are set one more time (safety)
if (response.tracks && response.tracks.length > 0) {
setTracks(response.tracks)
}
return
}
if (response.status === 'error') {
setError(response.error || 'Split failed')
setIsProcessing(false)
return
}
// Still processing poll again
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
pollingRef.current = setTimeout(pollStatus, 2000)
} catch (error) {
console.error('[Polling] Error:', error)
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
pollingRef.current = setTimeout(pollStatus, 3000)
}
}
useEffect(() => {
if (!taskId) {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
setWsConnected(false)
// Clear polling
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
return
}
const connect = () => {
const wsUrl = `/ws/${taskId}`
const ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log(`WebSocket connected for task ${taskId}`)
setWsConnected(true)
reconnectAttempts.current = 0
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
// Start polling when connection is established
// This ensures we get the final status even if WebSocket fails
setTimeout(() => {
pollStatus()
}, 1000)
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
console.log('[WebSocket] Message:', data)
if (data.type === 'status') {
const statusData = data.data
setStatus(statusData.status)
setProgress(statusData.progress)
setMessage(statusData.message)
if (statusData.error) {
setError(statusData.error)
}
if (statusData.tracks) {
setTracks(statusData.tracks)
}
if (statusData.status === 'done' || statusData.status === 'error') {
setIsProcessing(false)
}
} else if (data.type === 'progress') {
const progressData = data.data
setStatus(progressData.status)
setProgress(progressData.progress)
setMessage(progressData.message)
if (progressData.tracks) {
setTracks(progressData.tracks)
}
if (progressData.status === 'done') {
addLog('✅ Split complete!')
setIsProcessing(false)
} else if (progressData.status === 'error') {
setError(progressData.message)
addLog(`❌ Error: ${progressData.message}`)
setIsProcessing(false)
} else {
addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
}
}
} catch (error) {
console.error('[WebSocket] Failed to parse message:', error)
}
}
ws.onclose = () => {
console.log(`WebSocket disconnected for task ${taskId}`)
setWsConnected(false)
// If task is not done and we have a taskId, start polling
// We check the status store to see if it's already done
if (taskId && status !== 'done' && status !== 'error') {
console.log('[WebSocket] Disconnected while processing, starting polling...')
setTimeout(pollStatus, 1000)
}
}
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error)
// onclose will handle reconnection
}
wsRef.current = ws
}
connect()
return () => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
setWsConnected(false)
}
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing, status])
return wsRef.current
}
+18
View File
@@ -0,0 +1,18 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand'
import { SplitOptions } from '../types'
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
}
interface OptionsState {
options: SplitOptions
setOptions: (options: Partial<SplitOptions>) => void
reset: () => void
}
export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS },
setOptions: (newOptions) =>
set((state) => ({
options: { ...state.options, ...newOptions },
})),
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
}))
+54
View File
@@ -0,0 +1,54 @@
import { create } from 'zustand'
import { TrackInfo } from '../types'
interface TaskState {
taskId: string | null
status: 'pending' | 'processing' | 'done' | 'error' | null
progress: number
message: string
error: string | null
tracks: TrackInfo[]
logs: string[]
isProcessing: boolean
setTaskId: (taskId: string | null) => void
setStatus: (status: 'pending' | 'processing' | 'done' | 'error' | null) => void
setProgress: (progress: number) => void
setMessage: (message: string) => void
setError: (error: string | null) => void
setTracks: (tracks: TrackInfo[]) => void
addLog: (log: string) => void
setIsProcessing: (isProcessing: boolean) => void
reset: () => void
}
export const useTaskStore = create<TaskState>((set) => ({
taskId: null,
status: null,
progress: 0,
message: '',
error: null,
tracks: [],
logs: [],
isProcessing: false,
setTaskId: (taskId) => set({ taskId }),
setStatus: (status) => set({ status }),
setProgress: (progress) => set({ progress }),
setMessage: (message) => set({ message }),
setError: (error) => set({ error }),
setTracks: (tracks) => set({ tracks }),
addLog: (log) => set((state) => ({ logs: [...state.logs, log] })),
setIsProcessing: (isProcessing) => set({ isProcessing }),
reset: () =>
set({
taskId: null,
status: null,
progress: 0,
message: '',
error: null,
tracks: [],
logs: [],
isProcessing: false,
}),
}))
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand'
import { TracklistEntry } from '../types'
interface TracklistState {
rawText: string
entries: TracklistEntry[]
errors: { line: number; message: string }[]
isValid: boolean
isDragging: boolean
setRawText: (text: string) => void
setEntries: (entries: TracklistEntry[]) => void
setErrors: (errors: { line: number; message: string }[]) => void
setIsValid: (isValid: boolean) => void
setIsDragging: (isDragging: boolean) => void
reset: () => void
}
export const useTracklistStore = create<TracklistState>((set) => ({
rawText: '',
entries: [],
errors: [],
isValid: false,
isDragging: false,
setRawText: (rawText) => set({ rawText }),
setEntries: (entries) => set({ entries }),
setErrors: (errors) => set({ errors }),
setIsValid: (isValid) => set({ isValid }),
setIsDragging: (isDragging) => set({ isDragging }),
reset: () =>
set({
rawText: '',
entries: [],
errors: [],
isValid: false,
isDragging: false,
}),
}))
+31
View File
@@ -0,0 +1,31 @@
import { create } from 'zustand'
interface UIState {
theme: 'light' | 'dark'
isSidebarOpen: boolean
wsConnected: boolean
toggleTheme: () => void
setTheme: (theme: 'light' | 'dark') => void
toggleSidebar: () => void
setSidebarOpen: (isOpen: boolean) => void
setWsConnected: (connected: boolean) => void
}
export const useUIStore = create<UIState>((set) => ({
theme: 'light',
isSidebarOpen: false,
wsConnected: false,
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
setTheme: (theme) => set({ theme }),
toggleSidebar: () =>
set((state) => ({
isSidebarOpen: !state.isSidebarOpen,
})),
setSidebarOpen: (isSidebarOpen) => set({ isSidebarOpen }),
setWsConnected: (wsConnected) => set({ wsConnected }),
}))
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand'
interface UploadState {
file: File | null
taskId: string | null
fileName: string
fileSize: number
isUploading: boolean
uploadProgress: number
error: string | null
// New fields for stream info
hasVideo: boolean
hasAudio: boolean
hasSubtitle: boolean
audioCodec: string | null
setFile: (file: File | null) => void
setTaskId: (taskId: string | null) => void
setFileName: (name: string) => void
setFileSize: (size: number) => void
setIsUploading: (isUploading: boolean) => void
setUploadProgress: (progress: number) => void
setError: (error: string | null) => void
setHasVideo: (hasVideo: boolean) => void
setHasAudio: (hasAudio: boolean) => void
setHasSubtitle: (hasSubtitle: boolean) => void
setAudioCodec: (audioCodec: string | null) => void
reset: () => void
}
export const useUploadStore = create<UploadState>((set) => ({
file: null,
taskId: null,
fileName: '',
fileSize: 0,
isUploading: false,
uploadProgress: 0,
error: null,
hasVideo: false,
hasAudio: false,
hasSubtitle: false,
audioCodec: null,
setFile: (file) => set({ file }),
setTaskId: (taskId) => set({ taskId }),
setFileName: (fileName) => set({ fileName }),
setFileSize: (fileSize) => set({ fileSize }),
setIsUploading: (isUploading) => set({ isUploading }),
setUploadProgress: (uploadProgress) => set({ uploadProgress }),
setError: (error) => set({ error }),
setHasVideo: (hasVideo) => set({ hasVideo }),
setHasAudio: (hasAudio) => set({ hasAudio }),
setHasSubtitle: (hasSubtitle) => set({ hasSubtitle }),
setAudioCodec: (audioCodec) => set({ audioCodec }),
reset: () =>
set({
file: null,
taskId: null,
fileName: '',
fileSize: 0,
isUploading: false,
uploadProgress: 0,
error: null,
hasVideo: false,
hasAudio: false,
hasSubtitle: false,
audioCodec: null,
}),
}))
@@ -0,0 +1,11 @@
import { create } from 'zustand'
interface ValidationState {
formatError: string | null
setFormatError: (error: string | null) => void
}
export const useValidationStore = create<ValidationState>((set) => ({
formatError: null,
setFormatError: (error) => set({ formatError: error }),
}))
+53
View File
@@ -0,0 +1,53 @@
export interface TracklistEntry {
ts: string
tn?: string
an?: string
al?: string
date?: string
ext?: string
}
export interface TrackInfo {
filename: string
size: number
}
export interface TaskStatus {
task_id: string
status: 'pending' | 'processing' | 'done' | 'error'
progress: number
message: string
error: string | null
tracks: TrackInfo[]
}
export interface SplitOptions {
format: string
transcode_to?: string
drop_video: boolean
drop_subs: boolean
number_tracks: boolean
replace_bad_chars: boolean
replacement_char: string
bad_chars: string
skip_existing: boolean
output_template: string
album: string
comment: string
no_comment: boolean
comment_stream: number | null
merge_comments: boolean
comment_separator: string
tracklist_format: string
}
export interface UploadResponse {
task_id: string
filename: string
size: number
}
export interface SplitResponse {
task_id: string
status: string
}
+7
View File
@@ -0,0 +1,7 @@
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
+119
View File
@@ -0,0 +1,119 @@
// web/frontend/src/utils/parser.ts
import { TracklistEntry } from '../types'
type Token = { type: 'literal'; value: string } | { type: 'placeholder'; value: string }
export function parseFormat(formatStr: string): Token[] {
const validPlaceholders = new Set(['ts', 'tn', 'an', 'al', 'date', 'ext'])
const tokens: Token[] = []
let i = 0
while (i < formatStr.length) {
const ch = formatStr[i]
if (ch === '%') {
if (i + 1 < formatStr.length && formatStr[i + 1] === '%') {
tokens.push({ type: 'literal', value: '%' })
i += 2
continue
}
// match %letters
const match = formatStr.substring(i).match(/^%([a-zA-Z]+)/)
if (!match) {
throw new Error(`Invalid placeholder at position ${i}: '${formatStr.substring(i)}'`)
}
const placeholder = match[1]
if (!validPlaceholders.has(placeholder)) {
throw new Error(`Unknown placeholder '%${placeholder}'. Allowed: ${Array.from(validPlaceholders).join(', ')}`)
}
tokens.push({ type: 'placeholder', value: placeholder })
i += match[0].length
} else {
let j = i
while (j < formatStr.length && formatStr[j] !== '%') {
j++
}
tokens.push({ type: 'literal', value: formatStr.substring(i, j) })
i = j
}
}
return tokens
}
export function parseLine(line: string, tokens: Token[]): Record<string, string | null> {
line = line.trim()
if (!line) {
throw new Error('Empty line')
}
const result: Record<string, string | null> = {}
let pos = 0
for (let idx = 0; idx < tokens.length; idx++) {
const token = tokens[idx]
if (token.type === 'literal') {
const literal = token.value
if (!line.startsWith(literal, pos)) {
throw new Error(`Expected literal '${literal}' at position ${pos}, got '${line.substring(pos)}'`)
}
pos += literal.length
} else {
// placeholder
const placeholder = token.value
// If this is the last token, capture the rest
if (idx === tokens.length - 1) {
const value = line.substring(pos).trim()
result[placeholder] = value || null
pos = line.length
} else {
// Find the next literal to use as delimiter
let nextLiteral: string | null = null
for (let j = idx + 1; j < tokens.length; j++) {
if (tokens[j].type === 'literal') {
nextLiteral = tokens[j].value
break
}
}
if (nextLiteral === null) {
const value = line.substring(pos).trim()
result[placeholder] = value || null
pos = line.length
} else {
const nextPos = line.indexOf(nextLiteral, pos)
if (nextPos === -1) {
throw new Error(`Could not find literal '${nextLiteral}' after placeholder '${placeholder}'`)
}
const value = line.substring(pos, nextPos).trim()
result[placeholder] = value || null
pos = nextPos
}
}
}
}
return result
}
export function parseTracklistWithFormat(text: string, format: string): TracklistEntry[] {
const tokens = parseFormat(format)
const lines = text.split('\n').filter(line => line.trim() !== '')
const entries: TracklistEntry[] = []
for (const line of lines) {
try {
const parsed = parseLine(line, tokens)
const entry: TracklistEntry = {
ts: parsed.ts || '',
tn: parsed.tn || '',
an: parsed.an || '',
al: parsed.al || '',
date: parsed.date || '',
ext: parsed.ext || '',
}
entries.push(entry)
} catch (error) {
// We'll handle errors in the validator; just skip or mark as invalid
// For now, we'll push an empty entry with an error flag
entries.push({ ts: '', tn: line, an: '' })
}
}
return entries
}
+61
View File
@@ -0,0 +1,61 @@
// web/frontend/src/utils/validators.ts
import { TracklistEntry } from '../types'
import { parseTracklistWithFormat } from './parser'
export const validateTracklist = (
entries: TracklistEntry[]
): { isValid: boolean; errors: { line: number; message: string }[] } => {
const errors: { line: number; message: string }[] = []
entries.forEach((entry, index) => {
const lineNum = index + 1
if (!entry.ts || !entry.ts.trim()) {
errors.push({ line: lineNum, message: 'Missing timestamp (%ts)' })
} else {
// Validate timestamp format
const ts = entry.ts.trim()
if (!/^\d{1,2}:\d{2}(:\d{2})?$/.test(ts) && !/^\d{1,2}:\d{2}-\d{1,2}:\d{2}$/.test(ts)) {
errors.push({ line: lineNum, message: 'Invalid timestamp format. Expected mm:ss or mm:ss-HH:MM:SS' })
}
}
if (!entry.tn || !entry.tn.trim()) {
errors.push({ line: lineNum, message: 'Missing track name (%tn)' })
}
})
return {
isValid: errors.length === 0,
errors,
}
}
// New function that parses and validates using the format
export function parseAndValidateTracklist(text: string, format: string): {
entries: TracklistEntry[]
errors: { line: number; message: string }[]
isValid: boolean
} {
// We need to handle parsing errors gracefully.
// parseTracklistWithFormat will throw on some errors, but we can catch and mark as invalid.
try {
const entries = parseTracklistWithFormat(text, format)
const validation = validateTracklist(entries)
return {
entries,
errors: validation.errors,
isValid: validation.isValid,
}
} catch (error) {
// If parsing fails (e.g., invalid format), treat all lines as errors
const lines = text.split('\n').filter(line => line.trim() !== '')
const errors = lines.map((_, index) => ({
line: index + 1,
message: error instanceof Error ? error.message : 'Parse error',
}))
return {
entries: [],
errors,
isValid: false,
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Backend URL for proxy (default to localhost for local dev)
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: BACKEND_URL,
changeOrigin: true,
secure: false,
},
'/ws': {
target: BACKEND_URL.replace(/^http/, 'ws'),
ws: true,
changeOrigin: true,
secure: false,
},
},
},
})
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
python-multipart>=0.0.6
aiofiles>=23.2.0
pydantic>=2.5.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
websockets>=12.0