39 Commits

Author SHA1 Message Date
max 539ce3a801 feat (git): refactoring issue template added
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 54s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 58s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 13s
2026-09-02 12:45:38 +05:00
max 3abb66fbdf feat (git): adds feature request issue template 2026-09-02 12:37:08 +05:00
max 8fb68e76ab feat (git): adds a new issue template
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 20m33s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 2m5s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 17s
2026-09-02 12:21:47 +05:00
max 5e124645c0 Merge pull request 'feat(core): output format detected logic improved' (#4) from reverse-proxy into main
Reviewed-on: #4
2026-08-19 10:21:11 +04:00
max 02b730b082 CHANGE: maps codec/container/file_extension more accurate
Build and Push Docker Image / build-and-push-backend (pull_request) Failing after 19m21s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 54s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 11s
2026-08-19 11:12:48 +05:00
max c27f832c95 Update README.md 2026-08-07 06:35:53 +00:00
max 5cf6812338 Update README.md 2026-08-07 06:34:36 +00:00
max bb883409e1 run-name added for images building action 2026-08-05 17:11:43 +05:00
max f868de9d3b Merge pull request 'Reverse proxy config added, secret is used in build images action' (#3) from reverse-proxy into main
Reviewed-on: #3
2026-08-05 12:08:10 +00:00
max 800ad9c280 Building images action uses a secret to authenticate webhook server
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 1m2s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 49s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 13s
2026-08-05 16:57:12 +05:00
max bf6c6780da New deployment instructions added - simple NGINX reverse proxy example 2026-08-05 14:33:03 +05:00
max 96b1588fcc Merge pull request 'Ci cd merge request' (#2) from ci-cd into main
Reviewed-on: #2
2026-08-05 04:57:46 +00:00
max f00a70cb2e Merge pull request 'Web interface merge' (#1) from web-interface into main
Reviewed-on: #1
2026-08-05 04:56:17 +00:00
max 7f2b82aae0 .env.example is improved, comments added. docker-compose-prod.yaml is replaced with docker-compose.yaml, development version is docker-compose.override.yaml now
Build and Push Docker Image / build-and-push-backend (pull_request) Successful in 21m4s
Build and Push Docker Image / build-and-push-frontend (pull_request) Successful in 52s
Build and Push Docker Image / notify-deployment-server (pull_request) Successful in 13s
2026-08-05 09:52:23 +05:00
max a028b875fb building image actions is triggered only if a pull request has been merged into main 2026-08-05 09:07:00 +05:00
max d1a166a0f1 docker-compose-prod.yaml uses more variables from .env.example now 2026-08-05 08:52:45 +05:00
max fb9413e6f8 .env is untracked now 2026-08-04 21:50:06 +05:00
max e0490c5f41 .env.example is committed to the repo from now on. .env is used as a development file and ignored 2026-08-04 21:46:48 +05:00
max b6deed78c8 Building image action builds both images first, only then notifies the deployment server. Docker compose production file added 2026-08-04 20:41:21 +05:00
max e802b684ff Docker build context fixed in frontend building job
Build and Push Docker Image / build-and-push (push) Successful in 4m46s
2026-08-04 20:18:11 +05:00
max e28bcc27fc fixes in the build image action
Build and Push Docker Image / build-and-push (push) Failing after 1m27s
2026-08-04 20:12:41 +05:00
max 3a326859de Building image action fixed to use correct Dockerfile and build context
Build and Push Docker Image / build-and-push (push) Failing after 19m37s
2026-08-04 19:50:41 +05:00
max 26fb4fb3f4 Test CI/CD job was removed. Image building job was updated to build web version of the app
Build and Push Docker Image / build-and-push (push) Failing after 39s
2026-08-04 19:17:36 +05:00
max c455bd541b New test action added. It builds a docker image and pushes it to the registry
Build and Push Docker Image / build-and-push (push) Successful in 20m15s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 15s
2026-08-04 10:56:01 +05:00
max c66dbf9dc3 Test CI/CD file added
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 18s
2026-08-03 13:02:48 +05:00
max dcdae41706 Tracs validation added. A warning is thrown if the input contains a video strem, but the requested output format doesn't support it 2026-08-03 11:26:17 +05:00
max 1d1f8563c0 Backend: endpoint for single output download added 2026-08-03 10:53:13 +05:00
max e23b0f44ce Download section didn't appear. Resolved 2026-08-03 10:46:01 +05:00
max ea49920a12 Useless Dockerfile has been removed. .gitignore updated 2026-08-02 13:38:43 +05:00
max 6707a8e27e Working docker-compose.yaml added. Services start and work. 2026-08-02 13:19:19 +05:00
max cd3ce0337b Frontend and backend work together fine, complete workflow is implemented 2026-08-01 17:04:03 +05:00
max e87d8089bf Simple frontend added, tests are required 2026-08-01 08:41:25 +00:00
max b6ace3f68b Bugs are fixed in the backend, it works now 2026-08-01 08:40:53 +00:00
max cd56637d2a Simple web backend added with upload/status/process/download API 2026-07-30 12:24:37 +00:00
max 277c538f21 Merge pull request 'Docker package' (#2) from docker_package into main
Reviewed-on: #2
2026-07-30 10:28:15 +00:00
max bb4adb1886 README.md includes information about docker setup 2026-07-30 10:22:01 +00:00
max 6c72834933 test_data added to .dockerignore 2026-07-30 09:57:59 +00:00
max 9d30844a1e Docker image hardened, seems to be working stable 2026-07-30 09:46:31 +00:00
max 5b0bb25177 Test docker image, basic functionality 2026-07-29 20:05:17 +05:00
76 changed files with 8144 additions and 318 deletions
+45
View File
@@ -0,0 +1,45 @@
# Git
.git/
.gitignore
# Python cache
*.pyc
__pycache__/
*.pyo
*.pyd
# Build artifacts
build/
dist/
*.egg-info/
.eggs/
# Virtual environments
.venv
venv/
env/
ENV/
# IDE
.vscode/
.idea/
*.swp
# License (not needed for installation)
LICENSE
# Docker files (not needed in build context)
Dockerfile
.dockerignore
# Local test files
test_data/
*.mp3
*.flac
*.wav
*.txt
*.log
# Deployment
deploy/
+26
View File
@@ -0,0 +1,26 @@
# ------------------------------------------------------------------------------
# Production Environment Variables
# ------------------------------------------------------------------------------
# Backend
DEBUG=0
MAX_UPLOAD_SIZE_MB=500
CLEANUP_AFTER_SECONDS=3600
# Backend data directory (bind mount)
# This directory will store all temporary files during splitting.
# It can get large (audio files), so place it on a partition with enough space.
BACKEND_DATA_DIR=/var/lib/audio_splitter_data
# Ports
BACKEND_PORT=8000
FRONTEND_PORT=5173
# ------------------------------------------------------------------------------
# Development Overrides (docker-compose.override.yaml)
# ------------------------------------------------------------------------------
# These are only used when docker-compose.override.yaml is present.
# They override the production settings for local development.
# Frontend (development)
VITE_BACKEND_URL=http://localhost:8000
+126
View File
@@ -0,0 +1,126 @@
name: "Bug Report"
description: "Report a bug or unexpected behavior to help us improve"
title: "[BUG]: "
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
## ⚠️ Before You Begin
Please ensure you have:
- [ ] Searched existing issues to avoid duplicates
- [ ] Confirmed this is a bug, not a question or configuration problem
- type: textarea
id: description
attributes:
label: "📋 Description"
description: "Provide a clear and concise description of the bug"
placeholder: "What happened? What did you expect to happen instead?"
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: "🔁 Steps to Reproduce"
description: "Step-by-step instructions to reproduce the issue"
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: "✅ Expected Behavior"
description: "What you expected to happen"
placeholder: "A clear description of what should happen..."
validations:
required: true
- type: textarea
id: actual
attributes:
label: "❌ Actual Behavior"
description: "What actually happened"
placeholder: "Include error messages, stack traces, or unexpected outcomes..."
validations:
required: true
- type: textarea
id: logs
attributes:
label: "📄 Logs / Screenshots"
description: |
Provide relevant logs, error messages, or screenshots.
For logs, please use a pastebin and share the URL.
**Remember to remove any sensitive information (API keys, passwords, etc.).**[reference:13]
placeholder: "Paste logs here or provide a Gist URL..."
validations:
required: false
- type: markdown
attributes:
value: |
---
## 🖥️ Environment Details
Please fill out the relevant information below.
- type: input
id: version
attributes:
label: "📦 Audio Splitter Version"
description: "The version you are using (or commit reference)"
placeholder: "e.g., v1.21.7"
validations:
required: true
- type: input
id: os
attributes:
label: "💻 Operating System"
description: "Your OS and version"
placeholder: "e.g., Ubuntu 22.04, macOS Sonoma 14.5, Windows"
validations:
required: true
- type: input
id: browser
attributes:
label: "🌍 Browser (if applicable)"
description: "Browser name and version"
placeholder: "e.g., Chrome 120, Firefox 121, Safari 17"
validations:
required: false
- type: textarea
id: additional
attributes:
label: "📎 Additional Context"
description: "Any other information that might be relevant"
placeholder: |
- Database type and version (e.g., PostgreSQL 15, SQLite)
- Reverse proxy/CDN in use (e.g., Nginx, Cloudflare)[reference:15]
- Any custom configuration
- Related issues or PRs
validations:
required: false
- type: checkboxes
id: checklist
attributes:
label: "✅ Submission Checklist"
description: "Please confirm the following before submitting"
options:
- label: "I have searched for existing issues (open and closed) that report the same problem"
required: true
- label: "I am using the latest stable release of Audio Splitter"
required: true
- label: "I have provided clear steps to reproduce the issue"
required: true
- label: "I have included relevant logs or error messages (with sensitive info removed)"
required: false
+129
View File
@@ -0,0 +1,129 @@
name: "Feature Request"
description: "Suggest a new feature or enhancement for this project"
title: "[FEATURE]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
## ⚠️ Before You Begin
Please ensure you have:
- [ ] Searched existing issues (open and closed) to avoid duplicates
- [ ] Reviewed the [project documentation](https://git.vmn.su/max/audio_splitter/wiki) for existing functionality
- [ ] Confirmed this is a feature request, not a bug or configuration question
- type: textarea
id: problem
attributes:
label: "🎯 Problem Statement"
description: "What problem does this feature solve? What can't you do today?"
placeholder: |
I'm always frustrated when...
Currently, it's hard/impossible to...
This feature would help me because...
validations:
required: true
- type: textarea
id: solution
attributes:
label: "💡 Proposed Solution"
description: "Describe the solution you'd like to see"
placeholder: |
A clear description of what you want to happen.
If you have a specific API, UI, or implementation in mind, describe it here.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "🔄 Alternatives Considered"
description: "What alternative solutions or workarounds have you considered?"
placeholder: |
- Alternative A: ...
- Workaround B: ...
- Why these don't fully solve the problem
validations:
required: false
- type: textarea
id: usecase
attributes:
label: "👤 Use Case / Persona"
description: "Who benefits from this feature and in what context?"
placeholder: |
As a [type of user], I want to [do something] so that [I achieve some benefit].
Example: "As a project maintainer, I want to bulk-close issues by label so that I can clean up stale tickets faster."
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: "📸 Screenshots"
description: "If you can, provide screenshots or diagrams of the proposed feature"
placeholder: "Drag and drop images or paste links..."
validations:
required: false
- type: markdown
attributes:
value: |
---
## 🔧 Implementation Details (Optional)
The following sections are for contributors who want to implement this feature.
- type: textarea
id: technical
attributes:
label: "⚙️ Technical Approach"
description: "If you have ideas about how to implement this, share them here"
placeholder: |
- Which components/modules would be affected?
- Any database schema changes?
- API design considerations?
validations:
required: false
- type: textarea
id: acceptance
attributes:
label: "✅ Acceptance Criteria"
description: "What does 'done' look like for this feature?"
placeholder: |
- [ ] Feature works in the UI
- [ ] API endpoints are documented
- [ ] Tests are added
- [ ] Documentation is updated
validations:
required: false
- type: dropdown
id: contribution
attributes:
label: "🤝 Are you willing to contribute this feature?"
description: "Knowing if you can help implement this helps us prioritize"
options:
- "Yes, I can contribute"
- "Yes, but I need guidance"
- "No, but I can test/review"
- "No, I'm just requesting"
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: "✅ Submission Checklist"
description: "Please confirm the following before submitting"
options:
- label: "I have searched for existing issues (open and closed) that request the same feature"
required: true
- label: "I have explained the problem this feature solves"
required: true
- label: "I have described a concrete solution or direction"
required: true
- label: "I have considered alternatives and explained why they fall short"
required: false
+160
View File
@@ -0,0 +1,160 @@
name: "Refactor Request"
description: "Propose a code restructuring or cleanup with no behavior change"
title: "[REFACTOR]: "
labels: ["refactoring"]
body:
- type: markdown
attributes:
value: |
## ⚠️ Before You Begin
Please ensure you have:
- [ ] Searched existing issues (open and closed) to avoid duplicates
- [ ] Reviewed the [project documentation](https://git.vmn.su/max/audio_splitter/wiki) for architecture guidelines
- [ ] Confirmed this is a refactor (code restructuring with NO behavior change), not a bug fix or new feature
- type: textarea
id: problem
attributes:
label: "🎯 Problem Statement"
description: "What pain points does the current code cause? Why is this refactor needed?"
placeholder: |
The current implementation is difficult to maintain because...
This code has accumulated technical debt due to...
I'm constantly frustrated when working with this module because...
validations:
required: true
- type: textarea
id: scope
attributes:
label: "📂 Scope"
description: "Which files, modules, or components need refactoring? Be specific."
placeholder: |
- File paths: `src/auth/`, `internal/handler/`
- Component: `UserService` class
validations:
required: true
- type: textarea
id: current-state
attributes:
label: "🔍 Current State"
description: "Describe the current code structure, its issues, and why it's problematic"
placeholder: |
- The function `processUser()` is 500+ lines long
- Duplicated logic across 3 different files
- Mixed concerns (business logic + HTTP handling)
- Poor test coverage makes changes risky
- Naming is unclear and inconsistent
validations:
required: true
- type: textarea
id: proposed-changes
attributes:
label: "💡 Proposed Solution"
description: "What should the code look like after the refactor?"
placeholder: |
- Extract `processUser()` into smaller, focused functions
- Consolidate duplicated logic into a shared utility
- Separate business logic from HTTP handlers
- Introduce interfaces for better testability
- Rename variables and functions for clarity
validations:
required: true
- type: textarea
id: constraints
attributes:
label: "🚫 Constraints"
description: "What must NOT change? Public APIs, behavior, backward compatibility, performance characteristics?"
placeholder: |
- All public APIs must remain identical
- Database schema must not change
- External behavior must be identical
- Response formats must stay the same
- Performance must not degrade
validations:
required: true
- type: textarea
id: benefits
attributes:
label: "📈 Expected Benefits"
description: "What improvements will this refactor bring?"
placeholder: |
- Improved maintainability
- Better testability
- Reduced code duplication
- Clearer separation of concerns
- Easier onboarding for new contributors
validations:
required: false
- type: textarea
id: risks
attributes:
label: "⚠️ Risks & Mitigations"
description: "What could go wrong, and how will you mitigate it?"
placeholder: |
- Risk: Regression bugs
Mitigation: Comprehensive test coverage before and after
- Risk: Large diff making code review difficult
Mitigation: Break into smaller, incremental PRs
validations:
required: false
- type: textarea
id: testing
attributes:
label: "🧪 Testing Strategy"
description: "How will you ensure the refactor doesn't break existing functionality?"
placeholder: |
- Existing test suite must pass
- Add regression tests before refactoring
- Run benchmarks to ensure no performance regression
- Manual testing of critical paths
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: "🔄 Alternatives Considered"
description: "What other approaches did you consider, and why did you reject them?"
placeholder: |
- Alternative A: Complete rewrite — rejected because too risky
- Alternative B: Gradual deprecation — rejected because too slow
validations:
required: false
- type: dropdown
id: contribution
attributes:
label: "🤝 Are you willing to contribute this refactor?"
description: "Knowing if you can help implement this helps us prioritize"
options:
- "Yes, I can contribute"
- "Yes, but I need guidance"
- "No, but I can review"
- "No, I'm just suggesting"
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: "✅ Submission Checklist"
description: "Please confirm the following before submitting"
options:
- label: "I have searched for existing issues (open and closed) that request the same refactor"
required: true
- label: "I have clearly explained the problem this refactor solves"
required: true
- label: "I have described the scope of the refactor (specific files/modules)"
required: true
- label: "I have identified what must NOT change (APIs, behavior, backward compatibility)"
required: true
- label: "I have described how the refactor will be tested"
required: true
+64
View File
@@ -0,0 +1,64 @@
name: Build and Push Docker Image
run-name: Production images are being built
on:
pull_request:
types: [closed]
branches: [ main ]
env:
REGISTRY: git.vmn.su
OWNER: max
REPO: audio_splitter
jobs:
build-and-push-backend:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
run: |
echo "${{ secrets.CI_PUSHER_GITEA_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Build backend image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:${{ github.sha }} -f ./web/backend/Dockerfile .
docker tag ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:latest
- name: Push backend image
run: |
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_backend:latest
build-and-push-frontend:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
run: |
echo "${{ secrets.CI_PUSHER_GITEA_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
- name: Build frontend image
run: |
docker build -t ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:${{ github.sha }} -f ./web/frontend/Dockerfile ./web/frontend
docker tag ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:${{ github.sha }} ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:latest
- name: Push frontend image
run: |
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:${{ github.sha }}
docker push ${{ env.REGISTRY }}/${{ env.OWNER }}/${{ env.REPO }}_frontend:latest
notify-deployment-server:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- name: Trigger the webhook on the deployment server to start pulling the images
run: curl "https://webhook.vmn.su/hooks/deploy-audio-splitter?token=${{ secrets.CD_WEBHOOK_TOKEN }}"
+5
View File
@@ -3,3 +3,8 @@ build/
dist/
*.egg-info/
*.pyc
test_data/
venv/
web/frontend/node_modules
TODO.md
.env
+57
View File
@@ -0,0 +1,57 @@
FROM python:3.13-slim
# Install FFmpeg and dependencies required for gosu installation
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
wget \
gnupg \
dirmngr \
gnupg-agent && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Install gosu (lightweight tool for dropping privileges)
RUN set -eux; \
dpkgArch="$(dpkg --print-architecture | awk -F- '{ print $NF }')"; \
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch"; \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/1.17/gosu-$dpkgArch.asc"; \
export GNUPGHOME="$(mktemp -d)"; \
gpg --batch --keyserver hkps://keys.openpgp.org --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4; \
gpg --batch --verify /usr/local/bin/gosu.asc /usr/local/bin/gosu; \
gpgconf --kill all; \
rm -rf "$GNUPGHOME" /usr/local/bin/gosu.asc; \
chmod +x /usr/local/bin/gosu; \
gosu --version
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set working directory
WORKDIR /app
# Copy package metadata and source code
COPY setup.py pyproject.toml README.md ./
COPY audio_splitter/ ./audio_splitter/
# Install the package
RUN pip install --no-cache-dir .
# Create a non-root user with UID 1000
RUN addgroup --system --gid 1000 appgroup && \
adduser --system --uid 1000 --ingroup appgroup appuser
# Change ownership of /app to the container user (so it can write there if needed)
RUN chown -R appuser:appgroup /app
# Copy the entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set the entrypoint
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# Default command (shows help if no arguments provided)
CMD ["--help"]
+13 -237
View File
@@ -2,7 +2,8 @@
[![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/)
[![FFmpeg](https://img.shields.io/badge/FFmpeg-required-green.svg)](https://ffmpeg.org/)
[![License: MIT](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://opensource.org/license/gpl-3.0)
[![License: GPLv3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![Docker](https://img.shields.io/badge/Docker-ready-blue.svg)](https://www.docker.com/)
**Split audio files into tracks using a flexible tracklist format.**
@@ -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
- **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
- **Dryrun mode** Preview parsed tracks without splitting
- **Skip existing files** Avoid overwriting alreadyextracted tracks
- **Delete original** Optionally remove the input file after successful splitting
- **Docker image** Run without installing dependencies; simple oneline commands
---
## 📦 Installation
### Prerequisites
- **Python 3.8 or higher**
- **FFmpeg** required for audio processing
### Install FFmpeg
| OS | Command |
| -------------------- | ------------------------------------------------------------ |
| **Ubuntu/Debian** | `sudo apt install ffmpeg` |
| **macOS (Homebrew)** | `brew install ffmpeg` |
| **Windows** | Download from [ffmpeg.org](https://ffmpeg.org/download.html) |
### Install Audio Splitter
Clone the repository and install:
```bash
git clone https://github.com/yourusername/audio-splitter.git
cd audio-splitter
pip install .
```
Now the `audio_splitter` command is available globally:
```bash
audio_splitter input.mp3 tracks.txt
```
Or run directly without installation:
```bash
python -m audio_splitter.main input.mp3 tracklist.txt
```
---
## Quick Start
### 1. Prepare a tracklist file
Create a `tracks.txt` file with one track per line:
```
00:00 Intro
01:30 Song One - Artist A
04:20-06:45 Another Song - Artist B
08:10 Finale - Artist C
```
- `00:00` startonly timestamp (track ends at next track's start or end of file)
- `04:20-06:45` explicit start and end timestamps
### 2. Run the splitter
```bash
# If not installed
python -m audio_splitter.main my_album.mp3 tracks.txt
# If installed
audio_splitter my_album.mp3 tracks.txt
```
Output:
```
Found 4 tracks.
Detected streams: audio=True, video=False, subs=False
Audio codec: mp3
Output container: mp3
Extracting track 1: Intro (00:00:00 - 00:01:30)
-> Saved to: my_album_splits/Intro.mp3
Extracting track 2: Song One (00:01:30 - 00:04:20)
-> Saved to: my_album_splits/Song One - Artist A.mp3
...
Done!
```
---
## ⚙️ CommandLine Options
### Basic Options
| Option | Description |
| ---------------------- | ------------------------------------------------------------- |
| `input_file` | Input audio/video file |
| `tracklist_file` | Tracklist file |
| `-o, --output-dir DIR` | Output directory (default: `<input>_splits`) |
| `--format FORMAT` | Output container format (mp3, m4a, mkv, mp4, ogg, opus, etc.) |
| `--transcode-to CODEC` | Reencode audio to CODEC (e.g., libmp3lame, aac, libopus) |
| `--drop-video` | Remove video streams |
| `--drop-subs` | Remove subtitle streams |
| `--dry-run` | Preview parsed tracklist without splitting |
### Filename Options
| Option | Description |
| ---------------------------- | --------------------------------------------------------------- |
| `--number-tracks` | Prepend track number (`01 - `) to filenames |
| `--output-template TEMPLATE` | Custom filename template (default: `%an-%tn.%ext`) |
| `--replace-bad-chars` | Replace problematic characters in filenames |
| `--replacement-char CHAR` | Replacement character (default: `_`) |
| `--bad-chars CHARS` | Characters to replace (default includes space and single quote) |
### Metadata Options
| Option | Description |
| ------------------------- | ----------------------------------------------- |
| `--album ALBUM` | Set album name (overrides parsed `%al`) |
| `--comment COMMENT` | Set comment text |
| `--no-comment` | Ignore comment entirely |
| `--comment-stream INDEX` | Select comment from a specific stream (0based) |
| `--merge-comments` | Merge all comments from all streams |
| `--comment-separator SEP` | Separator for merged comments (default: `; `) |
### Tracklist Format Options
| Option | Description |
| --------------------------- | -------------------------------------------------- |
| `--tracklist-format FORMAT` | Custom tracklist format (default: `%ts %tn - %an`) |
| `--skip-existing` | Skip extraction if output file already exists |
---
## 📝 Placeholders Reference
### Tracklist Format Placeholders (`--tracklist-format`)
| Placeholder | Meaning |
| ----------- | --------------------------------------------------- |
| `%ts` | **Timestamp** required (`00:00` or `00:00-01:30`) |
| `%tn` | Track name |
| `%an` | Author/artist |
| `%al` | Album |
| `%date` | Date/year |
| `%ext` | File extension |
**Default:** `%ts %tn - %an`
### Output Template Placeholders (`--output-template`)
| Placeholder | Meaning |
| ----------- | -------------------------------------- |
| `%tn` | Track name |
| `%an` | Author/artist |
| `%al` | Album |
| `%date` | Date/year |
| `%ext` | File extension (without leading dot) |
| `%num` | Track number (zeropadded, e.g., `01`) |
**Default:** `%an-%tn.%ext`
---
## 💡 Examples
### Custom tracklist format
If your tracklist uses `artist - title [time]`:
```bash
python -m audio_splitter.main input.flac tracks.txt \
--tracklist-format "%an - %tn [%ts]"
```
### Custom output filenames
Name files as `01 - Artist - Song.mp3`:
```bash
python -m audio_splitter.main input.flac tracks.txt \
--output-template "%num - %an - %tn.%ext"
```
### Override album and comment
```bash
python -m audio_splitter.main input.flac tracks.txt \
--album "Greatest Hits" \
--comment "Live recording"
```
### Merge multiple comments from input file
```bash
python -m audio_splitter.main input.flac tracks.txt \
--merge-comments \
--comment-separator " | "
```
### Dryrun to preview parsing
```bash
python -m audio_splitter.main input.flac tracks.txt --dry-run
```
Output:
```
Parsed tracklist:
------------------------------------------------------------
ts | tn | an
------------------------------------------------------------
1 | 00:00 | Intro |
2 | 01:30 | Song One | Artist A
3 | 04:20-06:45 | Another Song | Artist B
...
------------------------------------------------------------
Dryrun complete. No files were created.
```
---
## 📂 Project Structure
```
audio_splitter/
├── __init__.py # Package initialisation
├── constants.py # Global constants (FORMAT_INFO, DEFAULT_BAD_CHARS)
├── utils.py # Generic helpers (timestamps, string manipulation)
├── tracklist.py # Tracklist parsing with custom formats
├── ffmpeg.py # FFmpeg/FFprobe interactions and command building
├── timestamp.py # Timestamp parsing and resolution
├── filename.py # Output filename generation
├── metadata.py # Metadata selection and building
├── formats.py # Container format decision and validation
├── core.py # Main orchestration logic
└── main.py # Commandline interface
```
---
## 🤝 Contributing
@@ -267,13 +42,13 @@ Contributions are welcome! Please follow these steps:
4. Push to the branch (`git push origin feature/amazing-feature`)
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
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
- [Python](https://www.python.org/) the language that makes it all possible
- [gosu](https://github.com/tianon/gosu) privilege dropping for Docker
---
## 📬 Contact
**Maintainer:** [Maxim Vershinin](https://git.vmn.su/max)
**Project Link:** [https://git.vmn.su/max/audio-splitter](https://git.vmn.su/max/audio-splitter)
**Maintainer:** Maxim Vershinin
**Project Link:** [https://git.vmn.su/max/audio_splitter](https://git.vmn.su/max/audio_splitter)
+1 -1
View File
@@ -44,7 +44,7 @@ def split_audio(input_file, output_directory, tracks, args):
# --------------------------------------------------------------------------
# 2. Format decision
# --------------------------------------------------------------------------
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}")
validate_format_compatibility(output_format, stream_info,
+107 -71
View File
@@ -1,7 +1,8 @@
"""FFmpeg / FFprobe interactions and command building."""
"""FFmpeg/FFprobe interaction utilities for the CLI and web backend."""
import subprocess
import json
import subprocess
from typing import Dict, List, Optional, Tuple
from .constants import FORMAT_INFO
from .utils import format_time
@@ -24,7 +25,10 @@ def get_audio_duration(input_file: str) -> float:
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
return float(result.stdout.strip())
try:
return float(result.stdout.strip())
except ValueError:
return 0.0
def has_stream_type(input_file: str, stream_type: str) -> bool:
@@ -49,7 +53,7 @@ def has_stream_type(input_file: str, stream_type: str) -> bool:
return bool(result.stdout.strip())
def get_audio_codec(input_file: str) -> str:
def get_audio_codec(input_file: str) -> Optional[str]:
"""
Return the codec name of the first audio stream.
@@ -71,7 +75,7 @@ def get_audio_codec(input_file: str) -> str:
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.
@@ -88,9 +92,97 @@ def get_stream_info(input_file: str):
'audio_codec': get_audio_codec(input_file)
}
def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_path,
stream_info, format_opt, transcode_audio,
drop_video, drop_subs, metadata=None):
def get_metadata(input_file: str) -> Dict[str, any]:
"""
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.
@@ -116,18 +208,17 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
'-t', format_time(duration_seconds)
]
# -------------------- Clear all original metadata --------------------
# Clear all original metadata.
cmd.append('-map_metadata')
cmd.append('-1')
# -------------------- Apply custom metadata --------------------
# Apply custom metadata.
if metadata:
for key, value in metadata.items():
if value is not None and value != '':
cmd.extend(['-metadata', f"{key}={value}"])
# -------------------- Stream mapping --------------------
# Map the streams we want to keep.
# Stream mapping.
if drop_video and drop_subs:
cmd.extend(['-map', '0:a:0'])
elif drop_video:
@@ -137,7 +228,7 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
else:
cmd.extend(['-map', '0'])
# -------------------- Audio codec --------------------
# Audio codec.
if transcode_audio:
cmd.extend(['-c:a', transcode_audio])
if transcode_audio in ('libmp3lame', 'mp3'):
@@ -147,77 +238,22 @@ def build_ffmpeg_command(input_file, start_seconds, duration_seconds, output_pat
else:
cmd.extend(['-c:a', 'copy'])
# -------------------- Video codec --------------------
# Video codec.
if not drop_video and stream_info['has_video']:
cmd.extend(['-c:v', 'copy'])
else:
cmd.append('-vn')
# -------------------- Subtitle codec --------------------
# Subtitle codec.
if not drop_subs and stream_info['has_subtitle']:
cmd.extend(['-c:s', 'copy'])
else:
cmd.append('-sn')
# -------------------- Output format --------------------
# Output format.
if format_opt:
ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt)
cmd.extend(['-f', ffmpeg_format])
# Overwrite output if it already exists.
cmd.extend(['-y', output_path])
return cmd
def get_metadata(input_file: str) -> dict:
"""
Retrieve metadata from the input file using ffprobe with JSON output.
Returns a dict with:
- album: merged from all sources (last wins)
- title: merged from all sources (last wins)
- comments: list of (stream_index, comment) tuples
"""
cmd = [
'ffprobe', '-v', 'quiet',
'-print_format', 'json',
'-show_entries', 'format_tags:stream_tags',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
if result.returncode != 0:
return {'album': None, 'title': None, 'comments': []}
try:
data = json.loads(result.stdout)
# Collect album and title (merged, last wins).
album = None
title = None
comments = [] # list of (stream_index, comment)
# Format tags.
fmt_tags = data.get('format', {}).get('tags', {})
album = fmt_tags.get('album') or album
title = fmt_tags.get('title') or title
# Format does not have a stream index; we'll treat it as -1 if needed.
# Stream tags.
for idx, stream in enumerate(data.get('streams', [])):
stream_tags = stream.get('tags', {})
# Album and title: update if present.
if 'album' in stream_tags:
album = stream_tags['album']
if 'title' in stream_tags:
title = stream_tags['title']
# Comment: collect all occurrences.
if 'comment' in stream_tags:
comments.append((idx, stream_tags['comment']))
return {
'album': album,
'title': title,
'comments': comments
}
except (json.JSONDecodeError, KeyError):
return {'album': None, 'title': None, 'comments': []}
+84 -9
View File
@@ -1,16 +1,79 @@
"""Container format decision and validation."""
from typing import Dict, Optional
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.
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:
stream_info: Dict from get_stream_info().
user_format: Userrequested format (or None).
transcode_audio: Audio codec to transcode to (or None).
transcode_audio: Audio codec to transcode to (or None) (unused in this function).
input_file: Path to the input file (optional, used to detect container and codec).
Returns:
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:
return user_format
# If video or subtitles exist, use MKV (which supports everything).
if stream_info['has_video'] or stream_info['has_subtitle']:
return 'matroska'
# If input_file is provided, try to detect container and codec
if input_file:
try:
from .ffmpeg import get_container_format, get_audio_codec
container = get_container_format(input_file)
codec = get_audio_codec(input_file)
fmt = determine_default_format(container, codec)
if fmt in FORMAT_INFO:
return fmt
except Exception:
# If detection fails, fall through to legacy logic
pass
# Audioonly: choose based on the current audio codec.
# Fallback: legacy behavior
if stream_info.get('has_video') or stream_info.get('has_subtitle'):
return 'matroska'
audio_codec = stream_info.get('audio_codec', '')
if audio_codec == 'mp3':
return 'mp3'
@@ -30,7 +104,8 @@ def determine_output_format(stream_info, user_format, transcode_audio):
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.
@@ -43,12 +118,12 @@ def validate_format_compatibility(format_name, stream_info, drop_video, drop_sub
return
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(
f"Format '{format_name}' does not support video streams. "
"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(
f"Format '{format_name}' does not support subtitle streams. "
"Please use --drop-subs or choose a container that supports subtitles."
+44
View File
@@ -0,0 +1,44 @@
# ------------------------------------------------------------------------------
# HTTPS Server Block (TLS Termination)
# ------------------------------------------------------------------------------
server {
listen 443 ssl;
server_name your.domain.tld;
ssl_certificate path;
ssl_certificate_key path;
# --------------------------------------------------------------------------
# Security Headers
# --------------------------------------------------------------------------
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# --------------------------------------------------------------------------
# Proxy to Frontend Container (everything goes through it)
# --------------------------------------------------------------------------
location / {
# Forward all traffic to the frontend container
proxy_pass http://127.0.0.1:5173; # <-- Frontend host port (adjust if needed)
proxy_http_version 1.1;
# Headers for correct client IP and protocol forwarding
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# The frontend container handles WebSocket upgrades internally,
# but we still need to pass the upgrade headers through.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Increase timeouts for long-running operations
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# Allow large file uploads (matches client_max_body_size in frontend NGINX)
client_max_body_size 500M;
}
}
+37
View File
@@ -0,0 +1,37 @@
services:
backend:
build:
context: .
dockerfile: web/backend/Dockerfile
volumes:
# Bind mount for source code (hot-reload for Python)
- ./web/backend:/app/backend
- ./audio_splitter:/app/audio_splitter
- ./setup.py:/app/setup.py
- ./pyproject.toml:/app/pyproject.toml
# Development data directory (overrides production)
- "./dev_data:/tmp/audio_splitter_web"
environment:
- DEBUG=1
- PYTHONUNBUFFERED=1
ports:
- "8000:8000"
frontend:
build:
context: ./web/frontend
# Use the same Dockerfile, but override CMD for development
dockerfile: Dockerfile
command: ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
volumes:
# Bind mount for source code (hot-reload for Vite)
- ./web/frontend:/app
- node_modules:/app/node_modules
environment:
- BACKEND_URL=http://backend:8000
- VITE_BACKEND_URL=http://backend:8000
ports:
- "5173:5173"
volumes:
node_modules:
+21
View File
@@ -0,0 +1,21 @@
services:
backend:
image: git.vmn.su/max/audio_splitter_backend:latest
ports:
- "${BACKEND_PORT:-8000}:8000"
volumes:
# Bind mount for persistent data storage.
# Set BACKEND_DATA_DIR in .env to point to your data directory.
- "${BACKEND_DATA_DIR:-/var/lib/audio_splitter_data}:/tmp/audio_splitter_web"
environment:
- PYTHONUNBUFFERED=1
- DEBUG=${DEBUG:-0}
restart: unless-stopped
frontend:
image: git.vmn.su/max/audio_splitter_frontend:latest
ports:
- "${FRONTEND_PORT:-5173}:80"
depends_on:
- backend
restart: unless-stopped
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
# Check if we are running as root (default)
if [ "$(id -u)" = "0" ]; then
# If /data is a directory, change its ownership to the container user
if [ -d "/data" ]; then
echo "Setting ownership of /data to appuser:appgroup"
chown -R appuser:appgroup /data
fi
# Drop privileges and run audio_splitter with the provided arguments
exec gosu appuser audio_splitter "$@"
else
# If not root, just run audio_splitter directly
# It's not supposed to be called
exec audio_splitter "$@"
fi
+18
View File
@@ -0,0 +1,18 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.venv
venv
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore
+4
View File
@@ -0,0 +1,4 @@
MAX_UPLOAD_SIZE_MB=500
TEMP_DIR=/tmp/audio_splitter_web
CLEANUP_AFTER_SECONDS=3600
ALLOW_ORIGINS=http://localhost:5173,http://localhost:3000
+45
View File
@@ -0,0 +1,45 @@
FROM python:3.13-slim
# Install FFmpeg, system dependencies, and gosu from APT
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
gosu \
&& \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set working directory
WORKDIR /app
# Copy requirements and install Python dependencies
COPY web/backend/requirements-web.txt .
RUN pip install --no-cache-dir -r requirements-web.txt
# Copy backend code
COPY web/backend /app/backend
# Copy and install audio_splitter package
COPY audio_splitter /app/audio_splitter
COPY setup.py pyproject.toml README.md /app/
RUN pip install --no-cache-dir /app
# Create a non-root user
RUN addgroup --system --gid 1000 appgroup && \
adduser --system --uid 1000 --ingroup appgroup appuser && \
chown -R appuser:appgroup /app
# Copy entrypoint script
COPY web/backend/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Set entrypoint
ENTRYPOINT ["/docker-entrypoint.sh"]
# Expose port
EXPOSE 8000
+8
View File
@@ -0,0 +1,8 @@
# From the web/ directory
cd web
# Install dependencies
pip install -r requirements-web.txt
# Run the server (note the module path: backend.main)
uvicorn backend.main:app --reload --port 8000
+1
View File
@@ -0,0 +1 @@
"""Audio Splitter Web Backend"""
+4
View File
@@ -0,0 +1,4 @@
"""API route handlers."""
from . import upload, split, status, download
# websocket is imported directly in main.py to avoid circular import
+90
View File
@@ -0,0 +1,90 @@
"""Download endpoint for split results."""
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from zipfile import ZipFile
import os
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.models.response import TaskStatus
router = APIRouter(prefix="/api", tags=["download"])
def _prepare_download(task_id: str):
"""Common logic to prepare and return the ZIP file."""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status["status"] != TaskStatus.DONE:
raise HTTPException(
status_code=400,
detail=f"Task {task_id} is not complete. Current status: {status['status']}"
)
output_dir = settings.temp_dir / task_id / "output"
if not output_dir.exists() or not any(output_dir.iterdir()):
raise HTTPException(status_code=404, detail="No output files found")
zip_path = settings.temp_dir / task_id / "splits.zip"
with ZipFile(zip_path, "w") as zipf:
for file_path in output_dir.iterdir():
if file_path.is_file():
zipf.write(file_path, arcname=file_path.name)
return zip_path
@router.get("/download/{task_id}")
async def download_results(task_id: str):
zip_path = _prepare_download(task_id)
return FileResponse(
path=zip_path,
media_type="application/zip",
filename=f"{task_id}_splits.zip",
headers={"Content-Disposition": f"attachment; filename={task_id}_splits.zip"}
)
@router.get("/download/{task_id}/splits.zip")
async def download_results_as_zip(task_id: str):
"""Alias endpoint that provides a .zip suffix for easier curl usage."""
zip_path = _prepare_download(task_id)
return FileResponse(
path=zip_path,
media_type="application/zip",
filename="splits.zip",
headers={"Content-Disposition": "attachment; filename=splits.zip"}
)
# NEW: Endpoint for downloading individual tracks
@router.get("/download/{task_id}/{filename}")
async def download_single_track(task_id: str, filename: str):
"""
Download a single track file from the output directory.
"""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status["status"] != TaskStatus.DONE:
raise HTTPException(
status_code=400,
detail=f"Task {task_id} is not complete. Current status: {status['status']}"
)
output_dir = settings.temp_dir / task_id / "output"
file_path = output_dir / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File '{filename}' not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/octet-stream",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
+25
View File
@@ -0,0 +1,25 @@
"""Endpoint to expose format information to the frontend."""
from fastapi import APIRouter
from backend.constants import FORMAT_INFO
router = APIRouter(prefix="/api", tags=["formats"])
@router.get("/formats")
async def get_formats():
"""
Return the list of supported container formats with their properties.
"""
return {
"formats": [
{
"name": name,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
}
+35
View File
@@ -0,0 +1,35 @@
"""Endpoint to retrieve stream information for an uploaded file."""
from fastapi import APIRouter, HTTPException
from backend.config import settings
from backend.services.file_manager import FileManager
from backend.services.task_manager import task_manager
from backend.ffmpeg import get_stream_info
router = APIRouter(prefix="/api", tags=["info"])
@router.get("/info/{task_id}")
async def get_task_info(task_id: str):
"""
Return stream information (has_audio, has_video, has_subtitle) for the uploaded file.
"""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
raise HTTPException(status_code=404, detail="Input file not found")
try:
info = get_stream_info(str(input_path))
return {
"task_id": task_id,
"has_audio": info["has_audio"],
"has_video": info["has_video"],
"has_subtitle": info["has_subtitle"],
"audio_codec": info["audio_codec"],
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
+39
View File
@@ -0,0 +1,39 @@
"""Split task endpoint."""
from fastapi import APIRouter, HTTPException, BackgroundTasks
from backend.models.request import SplitRequest
from backend.models.response import SplitResponse, TaskStatus
from backend.services.splitter import run_split_task
from backend.services.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["split"])
@router.post("/split", response_model=SplitResponse)
async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
task_id = request.task_id
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
raise HTTPException(
status_code=409,
detail=f"Task {task_id} is already {status['status']}"
)
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Preparing to split..."
)
background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options)
return SplitResponse(
task_id=task_id,
status=TaskStatus.PROCESSING
)
+25
View File
@@ -0,0 +1,25 @@
"""Status query endpoint."""
from fastapi import APIRouter, HTTPException
from backend.models.response import StatusResponse
from backend.services.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["status"])
@router.get("/status/{task_id}", response_model=StatusResponse)
async def get_status(task_id: str):
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
return StatusResponse(
task_id=task_id,
status=status["status"],
progress=status["progress"],
message=status["message"],
error=status.get("error"),
tracks=status.get("tracks", [])
)
+58
View File
@@ -0,0 +1,58 @@
"""File upload endpoint."""
import uuid
import shutil
from pathlib import Path
from fastapi import APIRouter, UploadFile, File, HTTPException
from backend.config import settings
from backend.models.response import UploadResponse
from backend.services.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["upload"])
ALLOWED_EXTENSIONS = {
".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus",
".aac", ".wma", ".aiff", ".alac", ".ac3"
}
@router.post("/upload", response_model=UploadResponse)
async def upload_file(file: UploadFile = File(...)):
extension = Path(file.filename).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Unsupported file format. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
)
task_id = str(uuid.uuid4())[:8]
task_dir = settings.temp_dir / task_id
task_dir.mkdir(parents=True, exist_ok=True)
input_path = task_dir / f"input{extension}"
try:
with open(input_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
shutil.rmtree(task_dir, ignore_errors=True)
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
file_size = input_path.stat().st_size
max_size_bytes = settings.max_upload_size_mb * 1024 * 1024
if file_size > max_size_bytes:
shutil.rmtree(task_dir, ignore_errors=True)
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size: {settings.max_upload_size_mb} MB"
)
# Create task entry in the task manager
task_manager.create_task(task_id, file.filename, file_size)
return UploadResponse(
task_id=task_id,
filename=file.filename,
size=file_size
)
+46
View File
@@ -0,0 +1,46 @@
"""WebSocket endpoint for realtime progress updates."""
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from backend.services.task_manager import task_manager
from backend.services.progress_publisher import register_connection, unregister_connection
router = APIRouter(tags=["websocket"])
@router.websocket("/ws/{task_id}")
async def websocket_endpoint(websocket: WebSocket, task_id: str):
await websocket.accept()
register_connection(task_id, websocket)
try:
# Send initial status
status = task_manager.get_status(task_id)
await websocket.send_json({
"type": "status",
"data": status
})
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("type") == "ping":
await websocket.send_json({"type": "pong"})
elif message.get("type") == "get_status":
status = task_manager.get_status(task_id)
await websocket.send_json({
"type": "status",
"data": status
})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
unregister_connection(task_id, websocket)
except Exception as e:
unregister_connection(task_id, websocket)
print(f"WebSocket error: {e}")
+33
View File
@@ -0,0 +1,33 @@
"""Configuration settings for the web backend."""
import os
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Server
host: str = "0.0.0.0"
port: int = 8000
debug: bool = False
# File handling
max_upload_size_mb: int = 500
temp_dir: Path = Path("/tmp/audio_splitter_web")
cleanup_after_seconds: int = 3600 # 1 hour
# CORS
allow_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
# Ensure temp directory exists
settings.temp_dir.mkdir(parents=True, exist_ok=True)
+12
View File
@@ -0,0 +1,12 @@
FORMAT_INFO = {
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -e
# Detect if we are running as root (default)
if [ "$(id -u)" = "0" ]; then
# Ensure the temp directory exists and set ownership
if [ -d "/tmp/audio_splitter_web" ]; then
echo "Setting ownership of /tmp/audio_splitter_web to appuser:appgroup"
chown -R appuser:appgroup /tmp/audio_splitter_web
else
echo "Creating /tmp/audio_splitter_web and setting ownership"
mkdir -p /tmp/audio_splitter_web
chown -R appuser:appgroup /tmp/audio_splitter_web
fi
# Drop privileges and run uvicorn using gosu
exec gosu appuser uvicorn backend.main:app --host 0.0.0.0 --port 8000
else
# If not root, just run directly
exec uvicorn backend.main:app --host 0.0.0.0 --port 8000
fi
+79
View File
@@ -0,0 +1,79 @@
"""FFmpeg/FFprobe interaction utilities for the web backend."""
import subprocess
import json
def get_stream_info(input_file: str):
"""
Retrieve stream information (audio, video, subtitle presence) from a media file.
Returns a dict with keys: has_audio, has_video, has_subtitle, audio_codec.
"""
# Get audio codec (if any)
audio_codec = None
try:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'a:0',
'-show_entries', 'stream=codec_name',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
codec = result.stdout.strip().lower()
if codec:
audio_codec = codec
except Exception:
pass
# Check for video stream
has_video = False
try:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'v',
'-show_entries', 'stream=codec_type',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
has_video = bool(result.stdout.strip())
except Exception:
pass
# Check for subtitle stream
has_subtitle = False
try:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 's',
'-show_entries', 'stream=codec_type',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
has_subtitle = bool(result.stdout.strip())
except Exception:
pass
return {
'has_audio': audio_codec is not None,
'has_video': has_video,
'has_subtitle': has_subtitle,
'audio_codec': audio_codec,
}
def get_audio_duration(input_file: str) -> float:
"""Get the duration of the audio file in seconds."""
cmd = [
'ffprobe', '-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
try:
return float(result.stdout.strip())
except ValueError:
return 0.0
+50
View File
@@ -0,0 +1,50 @@
"""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, 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(status.router)
app.include_router(download.router)
app.include_router(websocket.router)
app.include_router(formats.router) # new
app.include_router(info.router) # new
@app.get("/")
async def root():
return {"status": "ok", "service": "Audio Splitter Web API"}
@app.get("/health")
async def health():
return {"status": "healthy"}
+1
View File
@@ -0,0 +1 @@
"""Pydantic models for request/response validation."""
+58
View File
@@ -0,0 +1,58 @@
"""Request models for API endpoints."""
from typing import Optional, List
from pydantic import BaseModel, Field, validator
class TracklistEntry(BaseModel):
"""A single track entry from the tracklist."""
ts: str = Field(..., description="Timestamp (e.g., '00:00' or '00:00-01:30')")
tn: Optional[str] = Field("", description="Track name")
an: Optional[str] = Field("", description="Author/artist")
al: Optional[str] = Field("", description="Album")
date: Optional[str] = Field("", description="Date/year")
ext: Optional[str] = Field("", description="File extension")
@validator("ts")
def validate_timestamp(cls, v: str) -> str:
"""Basic timestamp validation (format and range)."""
v = v.strip()
if not v:
raise ValueError("Timestamp cannot be empty")
# Check for range format (start-end)
if "-" in v:
parts = v.split("-", 1)
start = parts[0].strip()
end = parts[1].strip()
if not start or not end:
raise ValueError("Invalid range format. Expected 'start-end'")
# Validate each part with the same logic
for ts in [start, end]:
cls._validate_single_timestamp(ts)
else:
cls._validate_single_timestamp(v)
return v
@staticmethod
def _validate_single_timestamp(ts: str) -> None:
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
parts = ts.split(":")
if len(parts) not in (2, 3):
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS")
try:
for p in parts:
int(p)
except ValueError:
raise ValueError(f"Invalid timestamp: {ts}. Must contain only numbers.")
class SplitRequest(BaseModel):
"""Request model for the split endpoint."""
task_id: str = Field(..., description="Task ID from upload")
tracklist: List[TracklistEntry] = Field(..., description="List of tracks")
options: dict = Field(default_factory=dict, description="All CLI options")
+38
View File
@@ -0,0 +1,38 @@
"""Response models for API endpoints."""
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field
class TaskStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
DONE = "done"
ERROR = "error"
class TrackInfo(BaseModel):
filename: str
size: int # bytes
class UploadResponse(BaseModel):
task_id: str
filename: str
size: int
class SplitResponse(BaseModel):
task_id: str
status: TaskStatus
class StatusResponse(BaseModel):
task_id: str
status: TaskStatus
progress: int = Field(0, ge=0, le=100)
message: str = ""
error: Optional[str] = None
tracks: List[TrackInfo] = []
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
python-multipart>=0.0.6
aiofiles>=23.2.0
pydantic>=2.5.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
websockets>=12.0
+1
View File
@@ -0,0 +1 @@
"""Business logic services."""
+47
View File
@@ -0,0 +1,47 @@
"""Temporary file management for web operations."""
import shutil
from pathlib import Path
from backend.config import settings
class FileManager:
@staticmethod
def get_task_dir(task_id: str) -> Path:
return settings.temp_dir / task_id
@staticmethod
def get_input_path(task_id: str) -> Path:
task_dir = FileManager.get_task_dir(task_id)
for f in task_dir.iterdir():
if f.name.startswith("input."):
return f
return None
@staticmethod
def get_output_dir(task_id: str) -> Path:
return FileManager.get_task_dir(task_id) / "output"
@staticmethod
def ensure_output_dir(task_id: str) -> Path:
output_dir = FileManager.get_output_dir(task_id)
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
@staticmethod
def cleanup_task(task_id: str) -> None:
task_dir = FileManager.get_task_dir(task_id)
if task_dir.exists():
shutil.rmtree(task_dir, ignore_errors=True)
@staticmethod
def get_output_files(task_id: str) -> list:
output_dir = FileManager.get_output_dir(task_id)
if not output_dir.exists():
return []
files = []
for f in output_dir.iterdir():
if f.is_file():
files.append({"filename": f.name, "size": f.stat().st_size})
return files
@@ -0,0 +1,66 @@
"""WebSocket progress publisher decouples task manager from WebSocket."""
import asyncio
from typing import Dict, Set
from fastapi import WebSocket
# This will be set by main.py during startup
MAIN_LOOP = None
active_connections: Dict[str, Set[WebSocket]] = {}
def publish_progress(task_id: str, progress: int, message: str, status: str = "processing"):
"""
Publish progress update to all connected WebSocket clients for a task.
"""
if task_id not in active_connections:
return
data = {
"type": "progress",
"data": {
"task_id": task_id,
"status": status,
"progress": progress,
"message": message
}
}
to_remove = set()
# Get the loop to use: either the stored one or try to get the current loop
loop = MAIN_LOOP
if loop is None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop, fallback to default event loop
loop = asyncio.get_event_loop()
for websocket in active_connections.get(task_id, set()):
try:
asyncio.run_coroutine_threadsafe(websocket.send_json(data), loop)
except Exception:
to_remove.add(websocket)
# Clean up disconnected clients
for websocket in to_remove:
active_connections[task_id].discard(websocket)
if task_id in active_connections and not active_connections[task_id]:
del active_connections[task_id]
def register_connection(task_id: str, websocket: WebSocket):
"""Register a WebSocket connection for a task."""
if task_id not in active_connections:
active_connections[task_id] = set()
active_connections[task_id].add(websocket)
def unregister_connection(task_id: str, websocket: WebSocket):
"""Unregister a WebSocket connection for a task."""
if task_id in active_connections:
active_connections[task_id].discard(websocket)
if not active_connections[task_id]:
del active_connections[task_id]
+103
View File
@@ -0,0 +1,103 @@
"""Splitter service that calls the core audio_splitter logic."""
import os
import sys
import shutil
from pathlib import Path
from typing import List, Dict, Any
import time
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.services.file_manager import FileManager
from backend.models.request import TracklistEntry
from backend.models.response import TaskStatus
def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None:
try:
task_manager.update_task_with_progress(
task_id, progress=5, message="Initializing..."
)
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
raise RuntimeError(f"Input file not found for task {task_id}")
output_dir = FileManager.ensure_output_dir(task_id)
tracks = []
for entry in tracklist:
track_dict = {
"ts": entry.ts,
"tn": entry.tn or "",
"an": entry.an or "",
"al": entry.al or "",
"date": entry.date or "",
"ext": entry.ext or ""
}
tracks.append(track_dict)
from types import SimpleNamespace
args = SimpleNamespace(
format=options.get("format", "mp3"),
transcode_to=options.get("transcode_to", None),
drop_video=options.get("drop_video", False),
drop_subs=options.get("drop_subs", False),
number_tracks=options.get("number_tracks", False),
replace_bad_chars=options.get("replace_bad_chars", False),
replacement_char=options.get("replacement_char", "_"),
bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
skip_existing=options.get("skip_existing", False),
output_template=options.get("output_template", "%an-%tn.%ext"),
album=options.get("album", None),
comment=options.get("comment", None),
no_comment=options.get("no_comment", False),
comment_stream=options.get("comment_stream", None),
merge_comments=options.get("merge_comments", False),
comment_separator=options.get("comment_separator", "; "),
delete_original=False,
)
project_root = Path(__file__).parent.parent.parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from audio_splitter.core import split_audio
task_manager.update_task_with_progress(
task_id, progress=10, message="Starting split..."
)
# Run the split
split_audio(str(input_path), str(output_dir), tracks, args)
# Get output files
output_files = FileManager.get_output_files(task_id)
# Final status update
task_manager.update_task_with_progress(
task_id,
progress=100,
message="Split complete",
status=TaskStatus.DONE
)
# Add tracks to the task state
task_manager.update_task(
task_id,
tracks=output_files
)
# Give WebSocket time to send the final message
time.sleep(0.5)
except Exception as e:
task_manager.update_task_with_progress(
task_id,
progress=0,
message="Split failed",
status=TaskStatus.ERROR,
error=str(e)
)
+110
View File
@@ -0,0 +1,110 @@
"""In-memory task state management."""
from typing import Dict, Optional, List
from datetime import datetime, timedelta
import threading
import time
from backend.config import settings
from backend.models.response import TaskStatus, TrackInfo
from backend.services.progress_publisher import publish_progress
class TaskManager:
def __init__(self):
self._tasks: Dict[str, dict] = {}
self._lock = threading.Lock()
def create_task(self, task_id: str, filename: str, file_size: int) -> None:
with self._lock:
self._tasks[task_id] = {
"task_id": task_id,
"filename": filename,
"file_size": file_size,
"status": TaskStatus.PENDING,
"progress": 0,
"message": "Upload complete",
"error": None,
"tracks": [],
"created_at": datetime.now(),
"updated_at": datetime.now()
}
def has_task(self, task_id: str) -> bool:
return task_id in self._tasks
def get_status(self, task_id: str) -> dict:
"""Get task status with datetime objects converted to ISO format."""
with self._lock:
if task_id not in self._tasks:
return {}
# Return a copy with datetime objects converted to ISO strings
status = self._tasks[task_id].copy()
return self._prepare_status_for_serialization(status)
def _prepare_status_for_serialization(self, status: dict) -> dict:
"""Convert datetime objects to ISO format strings for JSON serialization."""
serializable = {}
for key, value in status.items():
if isinstance(value, datetime):
serializable[key] = value.isoformat()
elif isinstance(value, list):
# Handle lists of objects (e.g., tracks)
serializable[key] = [
{k: v.isoformat() if isinstance(v, datetime) else v for k, v in item.items()}
if isinstance(item, dict)
else item
for item in value
]
elif isinstance(value, dict):
# Recursively handle nested dicts
serializable[key] = {
k: v.isoformat() if isinstance(v, datetime) else v
for k, v in value.items()
}
else:
serializable[key] = value
return serializable
def update_task(self, task_id: str, **kwargs) -> None:
with self._lock:
if task_id in self._tasks:
self._tasks[task_id].update(kwargs)
self._tasks[task_id]["updated_at"] = datetime.now()
def update_task_with_progress(self, task_id: str, progress: int, message: str,
status: str = None, error: str = None) -> None:
update_kwargs = {
"progress": progress,
"message": message
}
if status:
update_kwargs["status"] = status
if error is not None:
update_kwargs["error"] = error
self.update_task(task_id, **update_kwargs)
# Publish progress via WebSocket
publish_progress(task_id, progress, message, status or "processing")
def cleanup_old_tasks(self) -> None:
now = datetime.now()
timeout = timedelta(seconds=settings.cleanup_after_seconds)
with self._lock:
to_delete = []
for task_id, data in self._tasks.items():
if now - data["created_at"] > timeout:
to_delete.append(task_id)
for task_id in to_delete:
self._delete_task_files(task_id)
del self._tasks[task_id]
def _delete_task_files(self, task_id: str) -> None:
import shutil
task_dir = settings.temp_dir / task_id
if task_dir.exists():
shutil.rmtree(task_dir, ignore_errors=True)
task_manager = TaskManager()
+1
View File
@@ -0,0 +1 @@
"""Utility functions for the web backend."""
+23
View File
@@ -0,0 +1,23 @@
"""Input validation utilities."""
import re
from typing import List, Tuple
from ..models.request import TracklistEntry
def validate_tracklist(tracklist: List[TracklistEntry]) -> Tuple[bool, List[str]]:
"""
Validate a tracklist and return any errors.
Returns:
(is_valid, error_messages)
"""
errors = []
for idx, entry in enumerate(tracklist, 1):
# Each entry must have a timestamp
if not entry.ts or not entry.ts.strip():
errors.append(f"Line {idx}: Missing timestamp (%ts)")
# Additional validation could be added here
return len(errors) == 0, errors
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
*.log
.env
.env.local
.DS_Store
Dockerfile
Dockerfile.dev
.dockerignore
nginx.conf
+27
View File
@@ -0,0 +1,27 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm install
# Copy the application code and build
COPY . .
RUN npm run build
# Stage 2: Production (nginx)
FROM nginx:alpine
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Expose the port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Audio Splitter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
client_max_body_size 500M;
# Root directory for static files
root /usr/share/nginx/html;
index index.html;
# Serve React app
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy WebSocket requests to backend
location /ws/ {
proxy_pass http://backend:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+4239
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "audio-splitter-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@mui/x-data-grid": "^6.18.5",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}
+127
View File
@@ -0,0 +1,127 @@
import React from 'react'
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material'
import { Box, Grid, Button, CircularProgress, Typography } from '@mui/material'
import { PlayArrow } from '@mui/icons-material'
import { Layout } from './components/Layout'
import { UploadZone } from './components/UploadZone'
import { TracklistEditor } from './components/TracklistEditor'
import { OptionsPanel } from './components/OptionsPanel'
import { ProgressDisplay } from './components/ProgressDisplay'
import { DownloadSection } from './components/DownloadSection'
import { useUploadStore } from './stores/uploadStore'
import { useTracklistStore } from './stores/tracklistStore'
import { useOptionsStore } from './stores/optionsStore'
import { useTaskStore } from './stores/taskStore'
import { useUIStore } from './stores/uiStore'
import { useValidationStore } from './stores/validationStore'
import { useWebSocket } from './hooks/useWebSocket'
import { startSplit } from './api/client'
const App: React.FC = () => {
const { theme } = useUIStore()
const { taskId } = useUploadStore()
const { entries, isValid } = useTracklistStore()
const { options } = useOptionsStore()
const {
isProcessing,
setTaskId,
setError,
setIsProcessing,
addLog,
reset,
} = useTaskStore()
const { formatError } = useValidationStore()
useWebSocket(taskId && isProcessing ? taskId : null)
const handleSplit = async () => {
if (!taskId) {
alert('Please upload a file first')
return
}
if (!isValid) {
alert('Tracklist has errors. Please fix them before splitting.')
return
}
if (entries.length === 0) {
alert('Tracklist is empty')
return
}
try {
setTaskId(taskId)
setIsProcessing(true)
addLog('🚀 Starting split...')
const response = await startSplit(taskId, entries, options)
addLog(`✅ Split task started (ID: ${response.task_id})`)
} catch (error: any) {
setError(error.response?.data?.detail || error.message || 'Failed to start split')
addLog(`❌ Error: ${error.response?.data?.detail || error.message || 'Failed to start split'}`)
setIsProcessing(false)
}
}
const handleReset = () => {
reset()
}
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError
return (
<ThemeProvider
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<CssBaseline />
<Layout>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<UploadZone />
</Grid>
<Grid item xs={12} md={6}>
<TracklistEditor />
</Grid>
<Grid item xs={12} md={4}>
<OptionsPanel />
</Grid>
<Grid item xs={12} md={8}>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<Button
variant="contained"
color="success"
startIcon={isProcessing ? <CircularProgress size={20} color="inherit" /> : <PlayArrow />}
onClick={handleSplit}
disabled={isSplitDisabled}
sx={{ flex: 1 }}
>
{isProcessing ? 'Processing...' : 'Split'}
</Button>
<Button variant="outlined" color="secondary" onClick={handleReset} disabled={isProcessing}>
Reset
</Button>
</Box>
{formatError && (
<Box sx={{ mb: 2, p: 2, bgcolor: 'warning.light', borderRadius: 1 }}>
<Typography color="warning.dark" variant="body2">
{formatError}
</Typography>
</Box>
)}
<ProgressDisplay />
<DownloadSection />
</Grid>
</Grid>
</Layout>
</ThemeProvider>
)
}
export default App
+65
View File
@@ -0,0 +1,65 @@
import axios from 'axios'
import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
export const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
export const startSplit = async (
task_id: string,
tracklist: TracklistEntry[],
options: SplitOptions
): Promise<{ task_id: string; status: string }> => {
const response = await api.post('/split', {
task_id,
tracklist,
options,
})
return response.data
}
export const getStatus = async (task_id: string): Promise<TaskStatus> => {
const response = await api.get(`/status/${task_id}`)
return response.data
}
export const getDownloadUrl = (task_id: string): string => {
return `/api/download/${task_id}`
}
export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip`
}
// New functions for format validation feature
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
const response = await api.get('/formats')
return response.data
}
export const getTaskInfo = async (task_id: string): Promise<{
task_id: string
has_audio: boolean
has_video: boolean
has_subtitle: boolean
audio_codec: string | null
}> => {
const response = await api.get(`/info/${task_id}`)
return response.data
}
@@ -0,0 +1,73 @@
import React from 'react'
import { Paper, Typography, Button, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material'
import { Download, FolderZip } from '@mui/icons-material'
import { useTaskStore } from '../stores/taskStore'
import { getDownloadZipUrl } from '../api/client'
import { formatFileSize } from '../utils/formatters'
export const DownloadSection: React.FC = () => {
const { taskId, tracks, status } = useTaskStore()
console.log('[DownloadSection] Rendering:', { status, tracks, taskId })
// Check if we should show the download section
if (status !== 'done' || !tracks || tracks.length === 0 || !taskId) {
return null
}
// If we get here, we have tracks
console.log('[DownloadSection] Showing tracks:', tracks)
const handleDownloadZip = () => {
const url = getDownloadZipUrl(taskId)
window.open(url, '_blank')
}
const handleDownloadTrack = (filename: string) => {
const url = `/api/download/${taskId}/${filename}`
window.open(url, '_blank')
}
return (
<Paper sx={{ p: 3, mt: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
📥 Download Results
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<FolderZip />}
onClick={handleDownloadZip}
sx={{ mb: 2 }}
fullWidth
>
Download All as ZIP
</Button>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Individual Tracks
</Typography>
<List dense>
{tracks.map((track, index) => (
<ListItem
key={index}
secondaryAction={
<IconButton edge="end" onClick={() => handleDownloadTrack(track.filename)} size="small">
<Download />
</IconButton>
}
>
<ListItemText
primary={track.filename}
secondary={formatFileSize(track.size)}
/>
</ListItem>
))}
</List>
</Paper>
)
}
+57
View File
@@ -0,0 +1,57 @@
import React from 'react'
import { AppBar, Toolbar, Typography, IconButton, Box, Container, Badge } from '@mui/material'
import { Brightness4, Brightness7, FiberManualRecord } from '@mui/icons-material'
import { useUIStore } from '../stores/uiStore'
interface LayoutProps {
children: React.ReactNode
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const { theme, toggleTheme, wsConnected } = useUIStore()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static" color="default" elevation={1}>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
🎵 Audio Splitter
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Badge
color={wsConnected ? 'success' : 'error'}
variant="dot"
sx={{ mr: 1 }}
>
<FiberManualRecord
sx={{
fontSize: 12,
color: wsConnected ? 'green' : 'red',
visibility: 'hidden',
}}
/>
</Badge>
<Typography variant="caption" color="text.secondary">
{wsConnected ? 'Connected' : 'Disconnected'}
</Typography>
<IconButton onClick={toggleTheme} color="inherit">
{theme === 'light' ? <Brightness4 /> : <Brightness7 />}
</IconButton>
</Box>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ flex: 1, py: 4 }}>
{children}
</Container>
<Box component="footer" sx={{ py: 2, textAlign: 'center', borderTop: 1, borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary">
Audio Splitter v0.1.0 Built with
</Typography>
</Box>
</Box>
)
}
@@ -0,0 +1,307 @@
import React, { useEffect } from 'react'
import {
Box,
Paper,
Typography,
TextField,
MenuItem,
FormControlLabel,
Switch,
Collapse,
IconButton,
Divider,
Alert,
} from '@mui/material'
import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore'
interface SectionProps {
title: string
children: React.ReactNode
defaultExpanded?: boolean
}
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded)
return (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
py: 1,
}}
onClick={() => setExpanded(!expanded)}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{title}
</Typography>
<IconButton size="small">{expanded ? <ExpandLess /> : <ExpandMore />}</IconButton>
</Box>
<Divider />
<Collapse in={expanded}>
<Box sx={{ pt: 2, pb: 1 }}>{children}</Box>
</Collapse>
</Box>
)
}
export const OptionsPanel: React.FC = () => {
const { options, setOptions } = useOptionsStore()
const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore()
// Audio-only formats from backend constants (hardcoded for now)
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFormat = e.target.value
setOptions({ format: newFormat })
// Validate format
if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
setFormatError(
`Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
}
}
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked
setOptions({ drop_video: checked })
// Re-validate format
if (audioOnlyFormats.includes(options.format) && hasVideo && !checked) {
setFormatError(
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
}
}
// Re-validate when hasVideo changes (e.g., after upload)
useEffect(() => {
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
const newError = shouldShowError
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
: null
// Only update if the error state actually changes
if (newError !== formatError) {
setFormatError(newError)
}
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
return (
<Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Options
</Typography>
{formatError && (
<Alert severity="warning" sx={{ mb: 2 }}>
{formatError}
</Alert>
)}
{/* Output Settings */}
<Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Format"
select
value={options.format}
onChange={handleFormatChange}
fullWidth
size="small"
error={!!formatError}
>
<MenuItem value="mp3">MP3</MenuItem>
<MenuItem value="m4a">M4A</MenuItem>
<MenuItem value="mkv">MKV</MenuItem>
<MenuItem value="mp4">MP4</MenuItem>
<MenuItem value="ogg">OGG</MenuItem>
<MenuItem value="opus">OPUS</MenuItem>
<MenuItem value="flac">FLAC</MenuItem>
<MenuItem value="wav">WAV</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
</TextField>
<TextField
label="Transcode to"
select
value={options.transcode_to || ''}
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
fullWidth
size="small"
>
<MenuItem value="">Copy (no transcoding)</MenuItem>
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
<MenuItem value="libopus">OPUS</MenuItem>
</TextField>
{hasVideo && (
<FormControlLabel
control={
<Switch
checked={options.drop_video}
onChange={handleDropVideoChange}
/>
}
label="Drop video streams"
/>
)}
<FormControlLabel
control={
<Switch
checked={options.drop_subs}
onChange={(e) => handleChange('drop_subs', e.target.checked)}
/>
}
label="Drop subtitle streams"
/>
</Box>
</Section>
{/* Filename Settings */}
<Section title="Filename Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Output template"
value={options.output_template}
onChange={(e) => handleChange('output_template', e.target.value)}
fullWidth
size="small"
helperText="Placeholders: %tn (track name), %an (author), %al (album), %date, %ext, %num"
/>
<FormControlLabel
control={
<Switch
checked={options.number_tracks}
onChange={(e) => handleChange('number_tracks', e.target.checked)}
/>
}
label="Number tracks (01 - )"
/>
<FormControlLabel
control={
<Switch
checked={options.replace_bad_chars}
onChange={(e) => handleChange('replace_bad_chars', e.target.checked)}
/>
}
label="Replace bad characters"
/>
<TextField
label="Replacement character"
value={options.replacement_char}
onChange={(e) => handleChange('replacement_char', e.target.value)}
size="small"
disabled={!options.replace_bad_chars}
/>
<TextField
label="Bad characters list"
value={options.bad_chars}
onChange={(e) => handleChange('bad_chars', e.target.value)}
fullWidth
size="small"
disabled={!options.replace_bad_chars}
/>
<FormControlLabel
control={
<Switch
checked={options.skip_existing}
onChange={(e) => handleChange('skip_existing', e.target.checked)}
/>
}
label="Skip existing files"
/>
</Box>
</Section>
{/* Metadata Settings */}
<Section title="Metadata Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Album"
value={options.album}
onChange={(e) => handleChange('album', e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Comment"
value={options.comment}
onChange={(e) => handleChange('comment', e.target.value)}
fullWidth
size="small"
/>
<FormControlLabel
control={
<Switch
checked={options.no_comment}
onChange={(e) => handleChange('no_comment', e.target.checked)}
/>
}
label="No comment"
/>
<TextField
label="Comment stream index"
type="number"
value={options.comment_stream ?? ''}
onChange={(e) =>
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
}
size="small"
disabled={options.no_comment}
/>
<FormControlLabel
control={
<Switch
checked={options.merge_comments}
onChange={(e) => handleChange('merge_comments', e.target.checked)}
/>
}
label="Merge all comments"
disabled={options.no_comment}
/>
<TextField
label="Comment separator"
value={options.comment_separator}
onChange={(e) => handleChange('comment_separator', e.target.value)}
size="small"
disabled={!options.merge_comments || options.no_comment}
/>
</Box>
</Section>
{/* Tracklist Settings */}
<Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Tracklist Format"
value={options.tracklist_format}
onChange={(e) => handleChange('tracklist_format', e.target.value)}
fullWidth
size="small"
helperText="Placeholders: %ts (timestamp), %tn (track name), %an (author), %al (album), %date, %ext"
/>
</Box>
</Section>
</Paper>
)
// Helper function for option updates
function handleChange(field: string, value: any) {
setOptions({ [field]: value })
}
}
@@ -0,0 +1,107 @@
import React, { useEffect, useRef } from 'react'
import { Box, Paper, Typography, LinearProgress, Alert, Chip } from '@mui/material'
import { useTaskStore } from '../stores/taskStore'
export const ProgressDisplay: React.FC = () => {
const { status, progress, message, error, tracks, logs } = useTaskStore()
const logContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, [logs])
if (!status) {
return null
}
const getStatusColor = () => {
switch (status) {
case 'pending':
return 'info'
case 'processing':
return 'warning'
case 'done':
return 'success'
case 'error':
return 'error'
default:
return 'default'
}
}
const getStatusLabel = () => {
switch (status) {
case 'pending':
return 'Waiting'
case 'processing':
return 'Processing'
case 'done':
return 'Complete'
case 'error':
return 'Error'
default:
return 'Unknown'
}
}
return (
<Paper sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">📊 Progress</Typography>
<Chip label={getStatusLabel()} color={getStatusColor()} size="small" />
</Box>
<Box sx={{ mb: 2 }}>
<LinearProgress
variant="determinate"
value={progress}
color={status === 'error' ? 'error' : status === 'done' ? 'success' : 'primary'}
sx={{ height: 10, borderRadius: 5 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{progress}% {message}
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{tracks.length > 0 && status === 'done' && (
<Alert severity="success" sx={{ mb: 2 }}>
{tracks.length} track(s) extracted successfully!
</Alert>
)}
<Box
ref={logContainerRef}
sx={{
maxHeight: 200,
overflowY: 'auto',
bgcolor: 'background.default',
p: 2,
borderRadius: 1,
fontFamily: 'monospace',
fontSize: '12px',
lineHeight: 1.6,
}}
>
{logs.length === 0 ? (
<Typography variant="caption" color="text.secondary">
Waiting for progress updates...
</Typography>
) : (
logs.map((log, index) => (
<div key={index} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{log}
</div>
))
)}
</Box>
</Paper>
)
}
@@ -0,0 +1,138 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore'
import { useOptionsStore } from '../stores/optionsStore'
import { parseAndValidateTracklist } from '../utils/validators'
export const TracklistEditor: React.FC = () => {
const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false)
const validate = (text: string) => {
const result = parseAndValidateTracklist(text, options.tracklist_format)
setEntries(result.entries)
setErrors(result.errors)
setIsValid(result.isValid)
}
const handleTextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = event.target.value
setRawText(text)
validate(text)
}
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
const file = acceptedFiles[0]
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result as string
setRawText(text)
validate(text)
setIsDragging(false)
}
reader.readAsText(file)
},
[setRawText]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'text/plain': ['.txt'],
},
multiple: false,
})
// Re-validate when tracklist format changes
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [options.tracklist_format])
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
return (
<Paper
{...getRootProps()}
sx={{
p: 3,
border: isDragActive ? '2px dashed' : '1px solid',
borderColor: isDragActive ? 'primary.main' : 'divider',
borderRadius: 2,
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
transition: 'all 0.2s ease',
}}
>
<input {...getInputProps()} />
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Tracklist
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
{lineCount} track(s)
{isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
Format: {options.tracklist_format}
</Typography>
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
{/* Line numbers column */}
<Box
sx={{
minWidth: 40,
maxWidth: 40,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: 1.7,
color: 'text.secondary',
textAlign: 'right',
userSelect: 'none',
overflow: 'hidden',
}}
>
{rawText.split('\n').map((_, i) => (
<div key={i}>{i + 1}</div>
))}
</Box>
{/* Editor text area */}
<TextField
multiline
fullWidth
minRows={10}
maxRows={20}
value={rawText}
onChange={handleTextChange}
placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
variant="outlined"
sx={{
'& .MuiInputBase-root': {
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: 1.7,
},
}}
error={!isValid && errors.length > 0}
helperText={
!isValid && errors.length > 0
? errors.map((e) => `Line ${e.line}: ${e.message}`).join('; ')
: 'Drop a .txt file here or paste your tracklist'
}
/>
</Box>
{isDragActive && (
<Alert severity="info" sx={{ mt: 2 }}>
Drop your tracklist file (.txt) here
</Alert>
)}
</Paper>
)
}
+160
View File
@@ -0,0 +1,160 @@
import React, { useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
import { useUploadStore } from '../stores/uploadStore'
import { uploadFile, getTaskInfo } from '../api/client'
import { useTaskStore } from '../stores/taskStore'
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
export const UploadZone: React.FC = () => {
const {
file,
fileName,
fileSize,
isUploading,
uploadProgress,
error,
setFile,
setFileName,
setFileSize,
setIsUploading,
setUploadProgress,
setError,
setTaskId,
setHasVideo,
setHasAudio,
setHasSubtitle,
setAudioCodec,
} = useUploadStore()
const { setTaskId: setTaskIdStore } = useTaskStore()
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
const selectedFile = acceptedFiles[0]
const extension = '.' + selectedFile.name.split('.').pop()?.toLowerCase()
if (!ALLOWED_EXTENSIONS.includes(extension)) {
setError(`Unsupported file format. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`)
return
}
setFile(selectedFile)
setFileName(selectedFile.name)
setFileSize(selectedFile.size)
setError(null)
setIsUploading(true)
setUploadProgress(0)
try {
const response = await uploadFile(selectedFile)
const taskId = response.task_id
setTaskId(taskId)
setTaskIdStore(taskId)
setUploadProgress(100)
setIsUploading(false)
// Fetch stream info
try {
const info = await getTaskInfo(taskId)
setHasVideo(info.has_video)
setHasAudio(info.has_audio)
setHasSubtitle(info.has_subtitle)
setAudioCodec(info.audio_codec)
} catch (err) {
console.error('Failed to fetch stream info:', err)
// Don't block the upload flow if this fails; we'll just assume no video
}
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Upload failed')
setIsUploading(false)
setUploadProgress(0)
setFile(null)
setFileName('')
setFileSize(0)
}
},
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'audio/*': ALLOWED_EXTENSIONS,
},
multiple: false,
disabled: isUploading,
})
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return (
<Box>
<Paper
{...getRootProps()}
sx={{
p: 4,
border: '2px dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.300',
borderRadius: 2,
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
cursor: isUploading ? 'default' : 'pointer',
transition: 'all 0.2s ease',
textAlign: 'center',
}}
>
<input {...getInputProps()} />
{file ? (
<Box>
<InsertDriveFile sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
<Typography variant="h6">{fileName}</Typography>
<Typography variant="body2" color="text.secondary">
{formatFileSize(fileSize)}
</Typography>
{isUploading && (
<Box sx={{ mt: 2, width: '100%' }}>
<LinearProgress variant="determinate" value={uploadProgress} />
<Typography variant="caption" color="text.secondary">
{uploadProgress}% uploaded
</Typography>
</Box>
)}
{!isUploading && (
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
Uploaded successfully
</Typography>
)}
</Box>
) : (
<Box>
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
{isDragActive ? 'Drop your audio file here' : 'Drag & drop your audio file here'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
or click to browse
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
Supported formats: {ALLOWED_EXTENSIONS.join(', ')}
</Typography>
</Box>
)}
</Paper>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</Box>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useRef } from 'react'
import { useTaskStore } from '../stores/taskStore'
import { useUIStore } from '../stores/uiStore'
import { getStatus } from '../api/client'
export const useWebSocket = (taskId: string | null) => {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reconnectAttempts = useRef(0)
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const {
setStatus,
setProgress,
setMessage,
setError,
setTracks,
addLog,
setIsProcessing,
status,
} = useTaskStore()
const { setWsConnected } = useUIStore()
const pollStatus = async () => {
if (!taskId) return
try {
const response = await getStatus(taskId)
console.log('[Polling] Status response:', response)
// Update all state fields together
setStatus(response.status)
setProgress(response.progress)
setMessage(response.message)
// Explicitly set tracks if present
if (response.tracks && response.tracks.length > 0) {
console.log('[Polling] Setting tracks:', response.tracks)
setTracks(response.tracks)
}
if (response.status === 'done') {
console.log('[Polling] Split complete, tracks set:', response.tracks)
setIsProcessing(false)
// Ensure tracks are set one more time (safety)
if (response.tracks && response.tracks.length > 0) {
setTracks(response.tracks)
}
return
}
if (response.status === 'error') {
setError(response.error || 'Split failed')
setIsProcessing(false)
return
}
// Still processing poll again
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
pollingRef.current = setTimeout(pollStatus, 2000)
} catch (error) {
console.error('[Polling] Error:', error)
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
pollingRef.current = setTimeout(pollStatus, 3000)
}
}
useEffect(() => {
if (!taskId) {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
setWsConnected(false)
// Clear polling
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
return
}
const connect = () => {
const wsUrl = `/ws/${taskId}`
const ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log(`WebSocket connected for task ${taskId}`)
setWsConnected(true)
reconnectAttempts.current = 0
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
// Start polling when connection is established
// This ensures we get the final status even if WebSocket fails
setTimeout(() => {
pollStatus()
}, 1000)
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
console.log('[WebSocket] Message:', data)
if (data.type === 'status') {
const statusData = data.data
setStatus(statusData.status)
setProgress(statusData.progress)
setMessage(statusData.message)
if (statusData.error) {
setError(statusData.error)
}
if (statusData.tracks) {
setTracks(statusData.tracks)
}
if (statusData.status === 'done' || statusData.status === 'error') {
setIsProcessing(false)
}
} else if (data.type === 'progress') {
const progressData = data.data
setStatus(progressData.status)
setProgress(progressData.progress)
setMessage(progressData.message)
if (progressData.tracks) {
setTracks(progressData.tracks)
}
if (progressData.status === 'done') {
addLog('✅ Split complete!')
setIsProcessing(false)
} else if (progressData.status === 'error') {
setError(progressData.message)
addLog(`❌ Error: ${progressData.message}`)
setIsProcessing(false)
} else {
addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
}
}
} catch (error) {
console.error('[WebSocket] Failed to parse message:', error)
}
}
ws.onclose = () => {
console.log(`WebSocket disconnected for task ${taskId}`)
setWsConnected(false)
// If task is not done and we have a taskId, start polling
// We check the status store to see if it's already done
if (taskId && status !== 'done' && status !== 'error') {
console.log('[WebSocket] Disconnected while processing, starting polling...')
setTimeout(pollStatus, 1000)
}
}
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error)
// onclose will handle reconnection
}
wsRef.current = ws
}
connect()
return () => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
setWsConnected(false)
}
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing, status])
return wsRef.current
}
+18
View File
@@ -0,0 +1,18 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand'
import { SplitOptions } from '../types'
const DEFAULT_OPTIONS: SplitOptions = {
format: 'mp3',
transcode_to: '',
drop_video: false,
drop_subs: false,
number_tracks: false,
replace_bad_chars: false,
replacement_char: '_',
bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ',
skip_existing: false,
output_template: '%an-%tn.%ext',
album: '',
comment: '',
no_comment: false,
comment_stream: null,
merge_comments: false,
comment_separator: '; ',
tracklist_format: '%ts %tn - %an', // NEW
}
interface OptionsState {
options: SplitOptions
setOptions: (options: Partial<SplitOptions>) => void
reset: () => void
}
export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS },
setOptions: (newOptions) =>
set((state) => ({
options: { ...state.options, ...newOptions },
})),
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
}))
+54
View File
@@ -0,0 +1,54 @@
import { create } from 'zustand'
import { TrackInfo } from '../types'
interface TaskState {
taskId: string | null
status: 'pending' | 'processing' | 'done' | 'error' | null
progress: number
message: string
error: string | null
tracks: TrackInfo[]
logs: string[]
isProcessing: boolean
setTaskId: (taskId: string | null) => void
setStatus: (status: 'pending' | 'processing' | 'done' | 'error' | null) => void
setProgress: (progress: number) => void
setMessage: (message: string) => void
setError: (error: string | null) => void
setTracks: (tracks: TrackInfo[]) => void
addLog: (log: string) => void
setIsProcessing: (isProcessing: boolean) => void
reset: () => void
}
export const useTaskStore = create<TaskState>((set) => ({
taskId: null,
status: null,
progress: 0,
message: '',
error: null,
tracks: [],
logs: [],
isProcessing: false,
setTaskId: (taskId) => set({ taskId }),
setStatus: (status) => set({ status }),
setProgress: (progress) => set({ progress }),
setMessage: (message) => set({ message }),
setError: (error) => set({ error }),
setTracks: (tracks) => set({ tracks }),
addLog: (log) => set((state) => ({ logs: [...state.logs, log] })),
setIsProcessing: (isProcessing) => set({ isProcessing }),
reset: () =>
set({
taskId: null,
status: null,
progress: 0,
message: '',
error: null,
tracks: [],
logs: [],
isProcessing: false,
}),
}))
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand'
import { TracklistEntry } from '../types'
interface TracklistState {
rawText: string
entries: TracklistEntry[]
errors: { line: number; message: string }[]
isValid: boolean
isDragging: boolean
setRawText: (text: string) => void
setEntries: (entries: TracklistEntry[]) => void
setErrors: (errors: { line: number; message: string }[]) => void
setIsValid: (isValid: boolean) => void
setIsDragging: (isDragging: boolean) => void
reset: () => void
}
export const useTracklistStore = create<TracklistState>((set) => ({
rawText: '',
entries: [],
errors: [],
isValid: false,
isDragging: false,
setRawText: (rawText) => set({ rawText }),
setEntries: (entries) => set({ entries }),
setErrors: (errors) => set({ errors }),
setIsValid: (isValid) => set({ isValid }),
setIsDragging: (isDragging) => set({ isDragging }),
reset: () =>
set({
rawText: '',
entries: [],
errors: [],
isValid: false,
isDragging: false,
}),
}))
+31
View File
@@ -0,0 +1,31 @@
import { create } from 'zustand'
interface UIState {
theme: 'light' | 'dark'
isSidebarOpen: boolean
wsConnected: boolean
toggleTheme: () => void
setTheme: (theme: 'light' | 'dark') => void
toggleSidebar: () => void
setSidebarOpen: (isOpen: boolean) => void
setWsConnected: (connected: boolean) => void
}
export const useUIStore = create<UIState>((set) => ({
theme: 'light',
isSidebarOpen: false,
wsConnected: false,
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
setTheme: (theme) => set({ theme }),
toggleSidebar: () =>
set((state) => ({
isSidebarOpen: !state.isSidebarOpen,
})),
setSidebarOpen: (isSidebarOpen) => set({ isSidebarOpen }),
setWsConnected: (wsConnected) => set({ wsConnected }),
}))
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand'
interface UploadState {
file: File | null
taskId: string | null
fileName: string
fileSize: number
isUploading: boolean
uploadProgress: number
error: string | null
// New fields for stream info
hasVideo: boolean
hasAudio: boolean
hasSubtitle: boolean
audioCodec: string | null
setFile: (file: File | null) => void
setTaskId: (taskId: string | null) => void
setFileName: (name: string) => void
setFileSize: (size: number) => void
setIsUploading: (isUploading: boolean) => void
setUploadProgress: (progress: number) => void
setError: (error: string | null) => void
setHasVideo: (hasVideo: boolean) => void
setHasAudio: (hasAudio: boolean) => void
setHasSubtitle: (hasSubtitle: boolean) => void
setAudioCodec: (audioCodec: string | null) => void
reset: () => void
}
export const useUploadStore = create<UploadState>((set) => ({
file: null,
taskId: null,
fileName: '',
fileSize: 0,
isUploading: false,
uploadProgress: 0,
error: null,
hasVideo: false,
hasAudio: false,
hasSubtitle: false,
audioCodec: null,
setFile: (file) => set({ file }),
setTaskId: (taskId) => set({ taskId }),
setFileName: (fileName) => set({ fileName }),
setFileSize: (fileSize) => set({ fileSize }),
setIsUploading: (isUploading) => set({ isUploading }),
setUploadProgress: (uploadProgress) => set({ uploadProgress }),
setError: (error) => set({ error }),
setHasVideo: (hasVideo) => set({ hasVideo }),
setHasAudio: (hasAudio) => set({ hasAudio }),
setHasSubtitle: (hasSubtitle) => set({ hasSubtitle }),
setAudioCodec: (audioCodec) => set({ audioCodec }),
reset: () =>
set({
file: null,
taskId: null,
fileName: '',
fileSize: 0,
isUploading: false,
uploadProgress: 0,
error: null,
hasVideo: false,
hasAudio: false,
hasSubtitle: false,
audioCodec: null,
}),
}))
@@ -0,0 +1,11 @@
import { create } from 'zustand'
interface ValidationState {
formatError: string | null
setFormatError: (error: string | null) => void
}
export const useValidationStore = create<ValidationState>((set) => ({
formatError: null,
setFormatError: (error) => set({ formatError: error }),
}))
+53
View File
@@ -0,0 +1,53 @@
export interface TracklistEntry {
ts: string
tn?: string
an?: string
al?: string
date?: string
ext?: string
}
export interface TrackInfo {
filename: string
size: number
}
export interface TaskStatus {
task_id: string
status: 'pending' | 'processing' | 'done' | 'error'
progress: number
message: string
error: string | null
tracks: TrackInfo[]
}
export interface SplitOptions {
format: string
transcode_to?: string
drop_video: boolean
drop_subs: boolean
number_tracks: boolean
replace_bad_chars: boolean
replacement_char: string
bad_chars: string
skip_existing: boolean
output_template: string
album: string
comment: string
no_comment: boolean
comment_stream: number | null
merge_comments: boolean
comment_separator: string
tracklist_format: string
}
export interface UploadResponse {
task_id: string
filename: string
size: number
}
export interface SplitResponse {
task_id: string
status: string
}
+7
View File
@@ -0,0 +1,7 @@
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
+119
View File
@@ -0,0 +1,119 @@
// web/frontend/src/utils/parser.ts
import { TracklistEntry } from '../types'
type Token = { type: 'literal'; value: string } | { type: 'placeholder'; value: string }
export function parseFormat(formatStr: string): Token[] {
const validPlaceholders = new Set(['ts', 'tn', 'an', 'al', 'date', 'ext'])
const tokens: Token[] = []
let i = 0
while (i < formatStr.length) {
const ch = formatStr[i]
if (ch === '%') {
if (i + 1 < formatStr.length && formatStr[i + 1] === '%') {
tokens.push({ type: 'literal', value: '%' })
i += 2
continue
}
// match %letters
const match = formatStr.substring(i).match(/^%([a-zA-Z]+)/)
if (!match) {
throw new Error(`Invalid placeholder at position ${i}: '${formatStr.substring(i)}'`)
}
const placeholder = match[1]
if (!validPlaceholders.has(placeholder)) {
throw new Error(`Unknown placeholder '%${placeholder}'. Allowed: ${Array.from(validPlaceholders).join(', ')}`)
}
tokens.push({ type: 'placeholder', value: placeholder })
i += match[0].length
} else {
let j = i
while (j < formatStr.length && formatStr[j] !== '%') {
j++
}
tokens.push({ type: 'literal', value: formatStr.substring(i, j) })
i = j
}
}
return tokens
}
export function parseLine(line: string, tokens: Token[]): Record<string, string | null> {
line = line.trim()
if (!line) {
throw new Error('Empty line')
}
const result: Record<string, string | null> = {}
let pos = 0
for (let idx = 0; idx < tokens.length; idx++) {
const token = tokens[idx]
if (token.type === 'literal') {
const literal = token.value
if (!line.startsWith(literal, pos)) {
throw new Error(`Expected literal '${literal}' at position ${pos}, got '${line.substring(pos)}'`)
}
pos += literal.length
} else {
// placeholder
const placeholder = token.value
// If this is the last token, capture the rest
if (idx === tokens.length - 1) {
const value = line.substring(pos).trim()
result[placeholder] = value || null
pos = line.length
} else {
// Find the next literal to use as delimiter
let nextLiteral: string | null = null
for (let j = idx + 1; j < tokens.length; j++) {
if (tokens[j].type === 'literal') {
nextLiteral = tokens[j].value
break
}
}
if (nextLiteral === null) {
const value = line.substring(pos).trim()
result[placeholder] = value || null
pos = line.length
} else {
const nextPos = line.indexOf(nextLiteral, pos)
if (nextPos === -1) {
throw new Error(`Could not find literal '${nextLiteral}' after placeholder '${placeholder}'`)
}
const value = line.substring(pos, nextPos).trim()
result[placeholder] = value || null
pos = nextPos
}
}
}
}
return result
}
export function parseTracklistWithFormat(text: string, format: string): TracklistEntry[] {
const tokens = parseFormat(format)
const lines = text.split('\n').filter(line => line.trim() !== '')
const entries: TracklistEntry[] = []
for (const line of lines) {
try {
const parsed = parseLine(line, tokens)
const entry: TracklistEntry = {
ts: parsed.ts || '',
tn: parsed.tn || '',
an: parsed.an || '',
al: parsed.al || '',
date: parsed.date || '',
ext: parsed.ext || '',
}
entries.push(entry)
} catch (error) {
// We'll handle errors in the validator; just skip or mark as invalid
// For now, we'll push an empty entry with an error flag
entries.push({ ts: '', tn: line, an: '' })
}
}
return entries
}
+61
View File
@@ -0,0 +1,61 @@
// web/frontend/src/utils/validators.ts
import { TracklistEntry } from '../types'
import { parseTracklistWithFormat } from './parser'
export const validateTracklist = (
entries: TracklistEntry[]
): { isValid: boolean; errors: { line: number; message: string }[] } => {
const errors: { line: number; message: string }[] = []
entries.forEach((entry, index) => {
const lineNum = index + 1
if (!entry.ts || !entry.ts.trim()) {
errors.push({ line: lineNum, message: 'Missing timestamp (%ts)' })
} else {
// Validate timestamp format
const ts = entry.ts.trim()
if (!/^\d{1,2}:\d{2}(:\d{2})?$/.test(ts) && !/^\d{1,2}:\d{2}-\d{1,2}:\d{2}$/.test(ts)) {
errors.push({ line: lineNum, message: 'Invalid timestamp format. Expected mm:ss or mm:ss-HH:MM:SS' })
}
}
if (!entry.tn || !entry.tn.trim()) {
errors.push({ line: lineNum, message: 'Missing track name (%tn)' })
}
})
return {
isValid: errors.length === 0,
errors,
}
}
// New function that parses and validates using the format
export function parseAndValidateTracklist(text: string, format: string): {
entries: TracklistEntry[]
errors: { line: number; message: string }[]
isValid: boolean
} {
// We need to handle parsing errors gracefully.
// parseTracklistWithFormat will throw on some errors, but we can catch and mark as invalid.
try {
const entries = parseTracklistWithFormat(text, format)
const validation = validateTracklist(entries)
return {
entries,
errors: validation.errors,
isValid: validation.isValid,
}
} catch (error) {
// If parsing fails (e.g., invalid format), treat all lines as errors
const lines = text.split('\n').filter(line => line.trim() !== '')
const errors = lines.map((_, index) => ({
line: index + 1,
message: error instanceof Error ? error.message : 'Parse error',
}))
return {
entries: [],
errors,
isValid: false,
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Backend URL for proxy (default to localhost for local dev)
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: BACKEND_URL,
changeOrigin: true,
secure: false,
},
'/ws': {
target: BACKEND_URL.replace(/^http/, 'ws'),
ws: true,
changeOrigin: true,
secure: false,
},
},
},
})
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
python-multipart>=0.0.6
aiofiles>=23.2.0
pydantic>=2.5.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
websockets>=12.0