Compare commits
41 Commits
show
..
3c7ff97e76
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c7ff97e76 | |||
| 140a0fbc46 | |||
| 05fcc8ea29 | |||
| 52d01f1bcd | |||
| 607836b280 | |||
| 5e124645c0 | |||
| 02b730b082 | |||
| c27f832c95 | |||
| 5cf6812338 | |||
| bb883409e1 | |||
| f868de9d3b | |||
| 800ad9c280 | |||
| bf6c6780da | |||
| 96b1588fcc | |||
| f00a70cb2e | |||
| 7f2b82aae0 | |||
| a028b875fb | |||
| d1a166a0f1 | |||
| fb9413e6f8 | |||
| e0490c5f41 | |||
| b6deed78c8 | |||
| e802b684ff | |||
| e28bcc27fc | |||
| 3a326859de | |||
| 26fb4fb3f4 | |||
| c455bd541b | |||
| c66dbf9dc3 | |||
| dcdae41706 | |||
| 1d1f8563c0 | |||
| e23b0f44ce | |||
| ea49920a12 | |||
| 6707a8e27e | |||
| cd3ce0337b | |||
| e87d8089bf | |||
| b6ace3f68b | |||
| cd56637d2a | |||
| 277c538f21 | |||
| bb4adb1886 | |||
| 6c72834933 | |||
| 9d30844a1e | |||
| 5b0bb25177 |
@@ -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/
|
||||||
@@ -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
|
||||||
@@ -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 }}"
|
||||||
@@ -3,3 +3,8 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
test_data/
|
||||||
|
venv/
|
||||||
|
web/frontend/node_modules
|
||||||
|
TODO.md
|
||||||
|
.env
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
[](https://ffmpeg.org/)
|
[](https://ffmpeg.org/)
|
||||||
[](https://opensource.org/license/gpl-3.0)
|
[](https://www.gnu.org/licenses/gpl-3.0)
|
||||||
|
[](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
|
||||||
- **Dry‑run mode** – Preview parsed tracks without splitting
|
- **Dry‑run mode** – Preview parsed tracks without splitting
|
||||||
- **Skip existing files** – Avoid overwriting already‑extracted tracks
|
- **Skip existing files** – Avoid overwriting already‑extracted tracks
|
||||||
|
- **Delete original** – Optionally remove the input file after successful splitting
|
||||||
|
- **Docker image** – Run without installing dependencies; simple one‑line 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` – start‑only 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!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ Command‑Line 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` | Re‑encode 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 (0‑based) |
|
|
||||||
| `--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 (zero‑padded, 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 " | "
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dry‑run 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
|
|
||||||
...
|
|
||||||
------------------------------------------------------------
|
|
||||||
Dry‑run 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 # Command‑line 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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Default values for all configurable options shared between CLI, web backend, and frontend."""
|
||||||
|
|
||||||
|
# Output container format (used when user doesn't specify one; auto-detection overrides this)
|
||||||
|
DEFAULT_FORMAT = "mp3"
|
||||||
|
|
||||||
|
# Filename template
|
||||||
|
DEFAULT_OUTPUT_TEMPLATE = "%an-%tn.%ext"
|
||||||
|
|
||||||
|
# Character replacement
|
||||||
|
DEFAULT_REPLACEMENT_CHAR = "_"
|
||||||
|
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
|
||||||
|
|
||||||
|
# Tracklist parsing
|
||||||
|
DEFAULT_TRACKLIST_FORMAT = "%ts %tn - %an"
|
||||||
|
|
||||||
|
# Metadata defaults
|
||||||
|
DEFAULT_ALBUM = None
|
||||||
|
DEFAULT_COMMENT = None
|
||||||
|
DEFAULT_COMMENT_STREAM = None
|
||||||
|
DEFAULT_COMMENT_SEPARATOR = "; "
|
||||||
|
DEFAULT_NO_COMMENT = False
|
||||||
|
DEFAULT_MERGE_COMMENTS = False
|
||||||
|
|
||||||
|
# Stream handling
|
||||||
|
DEFAULT_DROP_VIDEO = False
|
||||||
|
DEFAULT_DROP_SUBS = False
|
||||||
|
|
||||||
|
# Filename/export options
|
||||||
|
DEFAULT_NUMBER_TRACKS = False
|
||||||
|
DEFAULT_REPLACE_BAD_CHARS = True
|
||||||
|
DEFAULT_SKIP_EXISTING = False
|
||||||
|
DEFAULT_DELETE_ORIGINAL = False
|
||||||
|
|
||||||
|
# Transcoding (None means copy codec)
|
||||||
|
DEFAULT_TRANSCODE_TO = None
|
||||||
+106
-70
@@ -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)
|
||||||
|
try:
|
||||||
return float(result.stdout.strip())
|
return float(result.stdout.strip())
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
def has_stream_type(input_file: str, stream_type: str) -> bool:
|
def has_stream_type(input_file: str, stream_type: str) -> bool:
|
||||||
@@ -49,7 +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': []}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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: User‑requested format (or None).
|
user_format: User‑requested 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
|
||||||
|
|
||||||
# Audio‑only: 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."
|
||||||
|
|||||||
+50
-116
@@ -1,133 +1,67 @@
|
|||||||
"""Command‑line interface and entry point."""
|
"""Command‑line interface and entry point."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import subprocess
|
import subprocess
|
||||||
import argparse
|
|
||||||
|
|
||||||
from .constants import DEFAULT_BAD_CHARS
|
from .constants import DEFAULT_BAD_CHARS
|
||||||
from .tracklist import read_tracklist, parse_format
|
from .defaults import (
|
||||||
|
DEFAULT_FORMAT,
|
||||||
|
DEFAULT_OUTPUT_TEMPLATE,
|
||||||
|
DEFAULT_REPLACEMENT_CHAR,
|
||||||
|
DEFAULT_BAD_CHARS,
|
||||||
|
DEFAULT_TRACKLIST_FORMAT,
|
||||||
|
DEFAULT_ALBUM,
|
||||||
|
DEFAULT_COMMENT,
|
||||||
|
DEFAULT_COMMENT_STREAM,
|
||||||
|
DEFAULT_COMMENT_SEPARATOR,
|
||||||
|
DEFAULT_NO_COMMENT,
|
||||||
|
DEFAULT_MERGE_COMMENTS,
|
||||||
|
DEFAULT_DROP_VIDEO,
|
||||||
|
DEFAULT_DROP_SUBS,
|
||||||
|
DEFAULT_NUMBER_TRACKS,
|
||||||
|
DEFAULT_REPLACE_BAD_CHARS,
|
||||||
|
DEFAULT_SKIP_EXISTING,
|
||||||
|
DEFAULT_DELETE_ORIGINAL,
|
||||||
|
DEFAULT_TRANSCODE_TO,
|
||||||
|
)
|
||||||
from .core import split_audio
|
from .core import split_audio
|
||||||
|
from .tracklist import read_tracklist, parse_format
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Parse arguments and start the splitting process."""
|
parser = argparse.ArgumentParser(description="Split audio file using a tracklist.")
|
||||||
parser = argparse.ArgumentParser(
|
parser.add_argument('input_file', help='Input audio file')
|
||||||
description="Split an audio file into tracks using a tracklist.",
|
|
||||||
epilog="Tracklist format: mm:ss track_name - author_name"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Positional arguments.
|
|
||||||
parser.add_argument('input_file', help='Input audio/video file')
|
|
||||||
parser.add_argument('tracklist_file', help='Tracklist file')
|
parser.add_argument('tracklist_file', help='Tracklist file')
|
||||||
|
|
||||||
# Optional arguments.
|
# Output options
|
||||||
parser.add_argument(
|
parser.add_argument('--format', default=DEFAULT_FORMAT, help=f"Output container format (default: {DEFAULT_FORMAT})")
|
||||||
'--output-dir', '-o',
|
parser.add_argument('--transcode-to', default=DEFAULT_TRANSCODE_TO, help="Audio codec to transcode to (default: copy)")
|
||||||
help='Output directory for split tracks (default: <input_basename>_splits)'
|
parser.add_argument('--drop-video', action='store_true', default=DEFAULT_DROP_VIDEO, help="Drop video streams")
|
||||||
)
|
parser.add_argument('--drop-subs', action='store_true', default=DEFAULT_DROP_SUBS, help="Drop subtitle streams")
|
||||||
parser.add_argument(
|
|
||||||
'--format',
|
|
||||||
help='Output container format (e.g., mp3, m4a, mkv, mp4, ogg, opus)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--transcode-to',
|
|
||||||
help='Re-encode audio to this codec (e.g., libmp3lame, aac, libopus)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--drop-video',
|
|
||||||
action='store_true',
|
|
||||||
help='Remove video streams from output'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--drop-subs',
|
|
||||||
action='store_true',
|
|
||||||
help='Remove subtitle streams from output'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--number-tracks',
|
|
||||||
action='store_true',
|
|
||||||
help='Prepend track number to output filenames (convenience; use %%num in template for full control)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--replace-bad-chars',
|
|
||||||
action='store_true',
|
|
||||||
help='Replace problematic characters in filenames (default: off)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--replacement-char',
|
|
||||||
default='_',
|
|
||||||
help='Character used as replacement (default: "_")'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--bad-chars',
|
|
||||||
default=DEFAULT_BAD_CHARS,
|
|
||||||
help='String of characters to replace (default includes space and single quote)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--skip-existing',
|
|
||||||
action='store_true',
|
|
||||||
help='Skip extraction if output file already exists (default: overwrite)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--tracklist-format',
|
|
||||||
default='%ts %tn - %an',
|
|
||||||
help='Format of each line in the tracklist using placeholders: '
|
|
||||||
'%%ts (timestamp), %%tn (track name), %%an (author), %%al (album), '
|
|
||||||
'%%date (date/year), %%ext (file extension). '
|
|
||||||
'Default: "%%ts %%tn - %%an"'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--output-template',
|
|
||||||
default='%an-%tn.%ext',
|
|
||||||
help='Template for output filenames using placeholders: '
|
|
||||||
'%%tn (track name), %%an (author), %%al (album), '
|
|
||||||
'%%date (date/year), %%ext (file extension), %%num (track number). '
|
|
||||||
'Default: "%%an-%%tn.%%ext"'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--dry-run',
|
|
||||||
action='store_true',
|
|
||||||
help='Parse and display the tracklist without splitting any files'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Metadata options.
|
# Filename options
|
||||||
parser.add_argument(
|
parser.add_argument('--number-tracks', action='store_true', default=DEFAULT_NUMBER_TRACKS, help="Prepend track numbers")
|
||||||
'--album',
|
parser.add_argument('--output-template', default=DEFAULT_OUTPUT_TEMPLATE, help=f"Output filename template (default: {DEFAULT_OUTPUT_TEMPLATE})")
|
||||||
help='Set album name in output metadata (overrides parsed %%al and original album)'
|
parser.add_argument('--replace-bad-chars', action='store_true', default=DEFAULT_REPLACE_BAD_CHARS, help="Replace bad characters")
|
||||||
)
|
parser.add_argument('--replacement-char', default=DEFAULT_REPLACEMENT_CHAR, help=f"Replacement character (default: {DEFAULT_REPLACEMENT_CHAR})")
|
||||||
parser.add_argument(
|
parser.add_argument('--bad-chars', default=DEFAULT_BAD_CHARS, help=f"Bad characters to replace (default: {DEFAULT_BAD_CHARS})")
|
||||||
'--comment',
|
parser.add_argument('--skip-existing', action='store_true', default=DEFAULT_SKIP_EXISTING, help="Skip existing output files")
|
||||||
help='Explicit comment text (overrides all other comment settings)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--no-comment',
|
|
||||||
action='store_true',
|
|
||||||
help='Explicitly ignore any comment (no comment written)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--comment-stream',
|
|
||||||
type=int,
|
|
||||||
default=None,
|
|
||||||
help='Select comment from a specific stream index (0‑based). Default: first stream with a comment.'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--merge-comments',
|
|
||||||
action='store_true',
|
|
||||||
help='Merge all comments from all streams into one (separated by --comment-separator)'
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
'--comment-separator',
|
|
||||||
default='; ',
|
|
||||||
help='Separator used when merging comments (default: "; ")'
|
|
||||||
)
|
|
||||||
|
|
||||||
# NEW: Delete original after successful split.
|
# Metadata options
|
||||||
parser.add_argument(
|
parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name")
|
||||||
'--delete-original',
|
parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment")
|
||||||
action='store_true',
|
parser.add_argument('--no-comment', action='store_true', default=DEFAULT_NO_COMMENT, help="Ignore comment")
|
||||||
help='Delete the original input file after successful splitting (default: keep)'
|
parser.add_argument('--comment-stream', type=int, default=DEFAULT_COMMENT_STREAM, help="Comment stream index")
|
||||||
)
|
parser.add_argument('--merge-comments', action='store_true', default=DEFAULT_MERGE_COMMENTS, help="Merge all comments")
|
||||||
|
parser.add_argument('--comment-separator', default=DEFAULT_COMMENT_SEPARATOR, help=f"Separator for merged comments (default: {DEFAULT_COMMENT_SEPARATOR})")
|
||||||
|
|
||||||
|
# Tracklist format
|
||||||
|
parser.add_argument('--tracklist-format', default=DEFAULT_TRACKLIST_FORMAT, help=f"Tracklist format (default: {DEFAULT_TRACKLIST_FORMAT})")
|
||||||
|
|
||||||
|
# Other
|
||||||
|
parser.add_argument('--delete-original', action='store_true', default=DEFAULT_DELETE_ORIGINAL, help="Delete original file after split")
|
||||||
|
parser.add_argument('--dry-run', action='store_true', help="Parse and display tracklist without splitting")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: web/backend/Dockerfile
|
||||||
|
#image: max/audio_splitter_backend:latest
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-8000}:8000"
|
||||||
|
volumes:
|
||||||
|
#- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
|
||||||
|
- "/tmp/test:/tmp/audio_splitter_web"
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- DEBUG=${DEBUG:-0}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: web/frontend/Dockerfile
|
||||||
|
#image: max/audio_splitter_frontend:latest
|
||||||
|
ports:
|
||||||
|
- "${FRONTEND_PORT:-5173}:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate TypeScript constants from audio_splitter/defaults.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Get the directory where this script is located
|
||||||
|
SCRIPT_DIR = Path(__file__).parent.absolute()
|
||||||
|
|
||||||
|
# The defaults.py is in the same directory as the script
|
||||||
|
DEFAULTS_PATH = SCRIPT_DIR / "defaults.py"
|
||||||
|
|
||||||
|
# The frontend source is at SCRIPT_DIR.parent (i.e., /app)
|
||||||
|
# The generated file should be at /app/src/constants/generated.ts
|
||||||
|
FRONTEND_ROOT = SCRIPT_DIR.parent
|
||||||
|
OUTPUT_PATH = FRONTEND_ROOT / "src" / "constants" / "generated.ts"
|
||||||
|
|
||||||
|
|
||||||
|
def load_defaults_module():
|
||||||
|
spec = importlib.util.spec_from_file_location("defaults", DEFAULTS_PATH)
|
||||||
|
if spec is None:
|
||||||
|
raise RuntimeError(f"Could not load spec for {DEFAULTS_PATH}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def format_value(value):
|
||||||
|
"""Convert Python value to TypeScript literal."""
|
||||||
|
if value is None:
|
||||||
|
return "null"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return str(value).lower()
|
||||||
|
if isinstance(value, str):
|
||||||
|
# Use json.dumps to produce a properly escaped string literal
|
||||||
|
return json.dumps(value)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return str(value)
|
||||||
|
if isinstance(value, list):
|
||||||
|
return f"[{', '.join(format_value(v) for v in value)}]"
|
||||||
|
return repr(value)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f"Generating TypeScript defaults from {DEFAULTS_PATH}")
|
||||||
|
defaults = load_defaults_module()
|
||||||
|
|
||||||
|
constants = {name: value for name, value in vars(defaults).items() if name.startswith("DEFAULT_")}
|
||||||
|
|
||||||
|
if not constants:
|
||||||
|
print("No DEFAULT_* constants found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"// ============================================================================",
|
||||||
|
"// GENERATED FILE – DO NOT EDIT MANUALLY.",
|
||||||
|
"// This file is generated from audio_splitter/defaults.py.",
|
||||||
|
"// Run `npm run generate` or `python scripts/generate_ts_defaults.py` to update.",
|
||||||
|
"// ============================================================================",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, value in sorted(constants.items()):
|
||||||
|
ts_value = format_value(value)
|
||||||
|
lines.append(f"export const {name} = {ts_value};")
|
||||||
|
|
||||||
|
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUTPUT_PATH.write_text("\n".join(lines) + "\n")
|
||||||
|
|
||||||
|
print(f"✅ Generated {OUTPUT_PATH}")
|
||||||
|
print(f" {len(constants)} constants exported.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Audio Splitter Web Backend"""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""API route handlers."""
|
||||||
|
|
||||||
|
from . import upload, split, split_file, status, download, formats, info
|
||||||
@@ -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}"}
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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)}")
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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", [])
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""WebSocket endpoint for real‑time 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}")
|
||||||
@@ -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)
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
Executable
+21
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Pydantic models for request/response validation."""
|
||||||
@@ -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")
|
||||||
@@ -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] = []
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Business logic services."""
|
||||||
@@ -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]
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Splitter service that calls the core audio_splitter logic."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Import central defaults
|
||||||
|
from audio_splitter.defaults import (
|
||||||
|
DEFAULT_FORMAT,
|
||||||
|
DEFAULT_OUTPUT_TEMPLATE,
|
||||||
|
DEFAULT_REPLACEMENT_CHAR,
|
||||||
|
DEFAULT_BAD_CHARS,
|
||||||
|
DEFAULT_ALBUM,
|
||||||
|
DEFAULT_COMMENT,
|
||||||
|
DEFAULT_NO_COMMENT,
|
||||||
|
DEFAULT_COMMENT_STREAM,
|
||||||
|
DEFAULT_MERGE_COMMENTS,
|
||||||
|
DEFAULT_COMMENT_SEPARATOR,
|
||||||
|
DEFAULT_DROP_VIDEO,
|
||||||
|
DEFAULT_DROP_SUBS,
|
||||||
|
DEFAULT_NUMBER_TRACKS,
|
||||||
|
DEFAULT_REPLACE_BAD_CHARS,
|
||||||
|
DEFAULT_SKIP_EXISTING,
|
||||||
|
DEFAULT_DELETE_ORIGINAL,
|
||||||
|
DEFAULT_TRANSCODE_TO,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Convert tracklist to dicts (CLI format)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Build args namespace using defaults where options not provided
|
||||||
|
args = SimpleNamespace(
|
||||||
|
format=options.get("format", DEFAULT_FORMAT),
|
||||||
|
transcode_to=options.get("transcode_to", DEFAULT_TRANSCODE_TO),
|
||||||
|
drop_video=options.get("drop_video", DEFAULT_DROP_VIDEO),
|
||||||
|
drop_subs=options.get("drop_subs", DEFAULT_DROP_SUBS),
|
||||||
|
number_tracks=options.get("number_tracks", DEFAULT_NUMBER_TRACKS),
|
||||||
|
replace_bad_chars=options.get("replace_bad_chars", DEFAULT_REPLACE_BAD_CHARS),
|
||||||
|
replacement_char=options.get("replacement_char", DEFAULT_REPLACEMENT_CHAR),
|
||||||
|
bad_chars=options.get("bad_chars", DEFAULT_BAD_CHARS),
|
||||||
|
skip_existing=options.get("skip_existing", DEFAULT_SKIP_EXISTING),
|
||||||
|
output_template=options.get("output_template", DEFAULT_OUTPUT_TEMPLATE),
|
||||||
|
album=options.get("album", DEFAULT_ALBUM),
|
||||||
|
comment=options.get("comment", DEFAULT_COMMENT),
|
||||||
|
no_comment=options.get("no_comment", DEFAULT_NO_COMMENT),
|
||||||
|
comment_stream=options.get("comment_stream", DEFAULT_COMMENT_STREAM),
|
||||||
|
merge_comments=options.get("merge_comments", DEFAULT_MERGE_COMMENTS),
|
||||||
|
comment_separator=options.get("comment_separator", DEFAULT_COMMENT_SEPARATOR),
|
||||||
|
delete_original=DEFAULT_DELETE_ORIGINAL, # never delete in web
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
task_manager.update_task(
|
||||||
|
task_id,
|
||||||
|
tracks=output_files
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Utility functions for the web backend."""
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
Dockerfile
|
||||||
|
Dockerfile.dev
|
||||||
|
.dockerignore
|
||||||
|
nginx.conf
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# web/frontend/Dockerfile
|
||||||
|
# Build context must be the project root.
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
# Install Python for the generation script
|
||||||
|
RUN apk add --no-cache python3 py3-pip
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy frontend package files and install dependencies
|
||||||
|
COPY web/frontend/package.json web/frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy the frontend source code
|
||||||
|
COPY web/frontend/ .
|
||||||
|
|
||||||
|
# Copy the Python defaults and the generation script into the expected location
|
||||||
|
# The generate script in package.json expects ../../scripts/generate_ts_defaults.py
|
||||||
|
# So we must place it at /app/../../scripts/ which is /scripts/
|
||||||
|
# But we can't COPY to a parent directory. Instead, we'll copy to /app/scripts/
|
||||||
|
# and adjust the package.json script to use ./scripts/generate_ts_defaults.py
|
||||||
|
# Actually, the simplest fix is to copy to /app/scripts/ and then adjust package.json.
|
||||||
|
#
|
||||||
|
# Let's use a different approach: copy to /app/scripts/ and update the generate script.
|
||||||
|
COPY audio_splitter/defaults.py /app/scripts/defaults.py
|
||||||
|
COPY scripts/generate_ts_defaults.py /app/scripts/generate_ts_defaults.py
|
||||||
|
|
||||||
|
# Run the generation script
|
||||||
|
RUN python /app/scripts/generate_ts_defaults.py
|
||||||
|
|
||||||
|
# Build the frontend
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Production (nginx)
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
COPY web/frontend/nginx/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+4239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "audio-splitter-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"generate": "python scripts/generate_ts_defaults.py",
|
||||||
|
"predev": "npm run generate",
|
||||||
|
"prebuild": "npm run generate",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// web/frontend/src/stores/optionsStore.ts
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { SplitOptions } from '../types'
|
||||||
|
import {
|
||||||
|
DEFAULT_FORMAT,
|
||||||
|
DEFAULT_OUTPUT_TEMPLATE,
|
||||||
|
DEFAULT_REPLACEMENT_CHAR,
|
||||||
|
DEFAULT_BAD_CHARS,
|
||||||
|
DEFAULT_ALBUM,
|
||||||
|
DEFAULT_COMMENT,
|
||||||
|
DEFAULT_NO_COMMENT,
|
||||||
|
DEFAULT_COMMENT_STREAM,
|
||||||
|
DEFAULT_MERGE_COMMENTS,
|
||||||
|
DEFAULT_COMMENT_SEPARATOR,
|
||||||
|
DEFAULT_DROP_VIDEO,
|
||||||
|
DEFAULT_DROP_SUBS,
|
||||||
|
DEFAULT_NUMBER_TRACKS,
|
||||||
|
DEFAULT_REPLACE_BAD_CHARS,
|
||||||
|
DEFAULT_SKIP_EXISTING,
|
||||||
|
DEFAULT_TRANSCODE_TO,
|
||||||
|
DEFAULT_TRACKLIST_FORMAT,
|
||||||
|
} from '../constants/generated'
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: SplitOptions = {
|
||||||
|
format: DEFAULT_FORMAT,
|
||||||
|
transcode_to: DEFAULT_TRANSCODE_TO ?? '',
|
||||||
|
drop_video: DEFAULT_DROP_VIDEO,
|
||||||
|
drop_subs: DEFAULT_DROP_SUBS,
|
||||||
|
number_tracks: DEFAULT_NUMBER_TRACKS,
|
||||||
|
replace_bad_chars: DEFAULT_REPLACE_BAD_CHARS,
|
||||||
|
replacement_char: DEFAULT_REPLACEMENT_CHAR,
|
||||||
|
bad_chars: DEFAULT_BAD_CHARS,
|
||||||
|
skip_existing: DEFAULT_SKIP_EXISTING,
|
||||||
|
output_template: DEFAULT_OUTPUT_TEMPLATE,
|
||||||
|
album: DEFAULT_ALBUM ?? '',
|
||||||
|
comment: DEFAULT_COMMENT ?? '',
|
||||||
|
no_comment: DEFAULT_NO_COMMENT,
|
||||||
|
comment_stream: DEFAULT_COMMENT_STREAM,
|
||||||
|
merge_comments: DEFAULT_MERGE_COMMENTS,
|
||||||
|
comment_separator: DEFAULT_COMMENT_SEPARATOR,
|
||||||
|
tracklist_format: DEFAULT_TRACKLIST_FORMAT,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OptionsState {
|
||||||
|
options: SplitOptions
|
||||||
|
setOptions: (options: Partial<SplitOptions>) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOptionsStore = create<OptionsState>((set) => ({
|
||||||
|
options: { ...DEFAULT_OPTIONS },
|
||||||
|
setOptions: (newOptions) =>
|
||||||
|
set((state) => ({
|
||||||
|
options: { ...state.options, ...newOptions },
|
||||||
|
})),
|
||||||
|
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
|
||||||
|
}))
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user