Compare commits
34 Commits
277c538f21
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 539ce3a801 | |||
| 3abb66fbdf | |||
| 8fb68e76ab | |||
| 5e124645c0 | |||
| 02b730b082 | |||
| c27f832c95 | |||
| 5cf6812338 | |||
| bb883409e1 | |||
| f868de9d3b | |||
| 800ad9c280 | |||
| bf6c6780da | |||
| 96b1588fcc | |||
| f00a70cb2e | |||
| 7f2b82aae0 | |||
| a028b875fb | |||
| d1a166a0f1 | |||
| fb9413e6f8 | |||
| e0490c5f41 | |||
| b6deed78c8 | |||
| e802b684ff | |||
| e28bcc27fc | |||
| 3a326859de | |||
| 26fb4fb3f4 | |||
| c455bd541b | |||
| c66dbf9dc3 | |||
| dcdae41706 | |||
| 1d1f8563c0 | |||
| e23b0f44ce | |||
| ea49920a12 | |||
| 6707a8e27e | |||
| cd3ce0337b | |||
| e87d8089bf | |||
| b6ace3f68b | |||
| cd56637d2a |
@@ -39,3 +39,7 @@ test_data/
|
||||
*.wav
|
||||
*.txt
|
||||
*.log
|
||||
|
||||
|
||||
# Deployment
|
||||
deploy/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# Production Environment Variables
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
# Backend
|
||||
DEBUG=0
|
||||
MAX_UPLOAD_SIZE_MB=500
|
||||
CLEANUP_AFTER_SECONDS=3600
|
||||
|
||||
# Backend data directory (bind mount)
|
||||
# This directory will store all temporary files during splitting.
|
||||
# It can get large (audio files), so place it on a partition with enough space.
|
||||
BACKEND_DATA_DIR=/var/lib/audio_splitter_data
|
||||
|
||||
# Ports
|
||||
BACKEND_PORT=8000
|
||||
FRONTEND_PORT=5173
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# Development Overrides (docker-compose.override.yaml)
|
||||
# ------------------------------------------------------------------------------
|
||||
# These are only used when docker-compose.override.yaml is present.
|
||||
# They override the production settings for local development.
|
||||
|
||||
# Frontend (development)
|
||||
VITE_BACKEND_URL=http://localhost:8000
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
-1
@@ -3,4 +3,8 @@ build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
*.pyc
|
||||
test_data/
|
||||
test_data/
|
||||
venv/
|
||||
web/frontend/node_modules
|
||||
TODO.md
|
||||
.env
|
||||
|
||||
+1
-1
@@ -54,4 +54,4 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
|
||||
# Default command (shows help if no arguments provided)
|
||||
CMD ["--help"]
|
||||
CMD ["--help"]
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
Full documentation – including all CLI options, tracklist formats, deployment guides, and architecture details – is available in the [**Wiki**](https://git.vmn.su/max/audio_splitter/wiki).
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Flexible tracklist parsing** – Define your own format with placeholders (`%ts`, `%tn`, `%an`, `%al`, `%date`, `%ext`)
|
||||
@@ -26,310 +30,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Python 3.8 or higher**
|
||||
- **FFmpeg** – required for audio processing
|
||||
|
||||
### Install FFmpeg
|
||||
|
||||
| OS | Command |
|
||||
| -------------------- | ------------------------------------------------------------ |
|
||||
| **Ubuntu/Debian** | `sudo apt install ffmpeg` |
|
||||
| **macOS (Homebrew)** | `brew install ffmpeg` |
|
||||
| **Windows** | Download from [ffmpeg.org](https://ffmpeg.org/download.html) |
|
||||
|
||||
### Install Audio Splitter
|
||||
|
||||
Clone the repository and install:
|
||||
|
||||
```bash
|
||||
git clone https://git.vmn.su/max/audio_splitter.git
|
||||
cd audio_splitter
|
||||
pip install .
|
||||
```
|
||||
|
||||
Now the `audio_splitter` command is available globally:
|
||||
|
||||
```bash
|
||||
audio_splitter input.mp3 tracks.txt
|
||||
```
|
||||
|
||||
> **Tip:** For development, install in editable mode: `pip install -e .`
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Prepare a tracklist file
|
||||
|
||||
Create a `tracks.txt` file with one track per line:
|
||||
|
||||
```
|
||||
00:00 Intro
|
||||
01:30 Song One - Artist A
|
||||
04:20-06:45 Another Song - Artist B
|
||||
08:10 Finale - Artist C
|
||||
```
|
||||
|
||||
- `00:00` – start‑only timestamp (track ends at next track's start or end of file)
|
||||
- `04:20-06:45` – explicit start and end timestamps
|
||||
|
||||
### 2. Run the splitter
|
||||
|
||||
```bash
|
||||
audio_splitter my_album.mp3 tracks.txt
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Found 4 tracks.
|
||||
Detected streams: audio=True, video=False, subs=False
|
||||
Audio codec: mp3
|
||||
Output container: mp3
|
||||
Extracting track 1: Intro (00:00:00 - 00:01:30)
|
||||
-> Saved to: my_album_splits/Intro.mp3
|
||||
Extracting track 2: Song One (00:01:30 - 00:04:20)
|
||||
-> Saved to: my_album_splits/Song One - Artist A.mp3
|
||||
...
|
||||
Done!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Command‑Line Options
|
||||
|
||||
### Basic Options
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------- | ------------------------------------------------------------- |
|
||||
| `input_file` | Input audio/video file |
|
||||
| `tracklist_file` | Tracklist file |
|
||||
| `-o, --output-dir DIR` | Output directory (default: `<input>_splits`) |
|
||||
| `--format FORMAT` | Output container format (mp3, m4a, mkv, mp4, ogg, opus, etc.) |
|
||||
| `--transcode-to CODEC` | Re‑encode audio to CODEC (e.g., libmp3lame, aac, libopus) |
|
||||
| `--drop-video` | Remove video streams |
|
||||
| `--drop-subs` | Remove subtitle streams |
|
||||
| `--dry-run` | Preview parsed tracklist without splitting |
|
||||
|
||||
### Filename Options
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------------- | --------------------------------------------------------------- |
|
||||
| `--number-tracks` | Prepend track number (`01 - `) to filenames |
|
||||
| `--output-template TEMPLATE` | Custom filename template (default: `%an-%tn.%ext`) |
|
||||
| `--replace-bad-chars` | Replace problematic characters in filenames |
|
||||
| `--replacement-char CHAR` | Replacement character (default: `_`) |
|
||||
| `--bad-chars CHARS` | Characters to replace (default includes space and single quote) |
|
||||
|
||||
### Metadata Options
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `--album ALBUM` | Set album name (overrides parsed `%al`) |
|
||||
| `--comment COMMENT` | Set comment text |
|
||||
| `--no-comment` | Ignore comment entirely |
|
||||
| `--comment-stream INDEX` | Select comment from a specific stream (0‑based) |
|
||||
| `--merge-comments` | Merge all comments from all streams |
|
||||
| `--comment-separator SEP` | Separator for merged comments (default: `; `) |
|
||||
|
||||
### Tracklist Format Options
|
||||
|
||||
| Option | Description |
|
||||
| --------------------------- | -------------------------------------------------- |
|
||||
| `--tracklist-format FORMAT` | Custom tracklist format (default: `%ts %tn - %an`) |
|
||||
| `--skip-existing` | Skip extraction if output file already exists |
|
||||
|
||||
### Other Options
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | --------------------------------------------------------- |
|
||||
| `--delete-original` | Delete the original input file after successful splitting |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Placeholders Reference
|
||||
|
||||
### Tracklist Format Placeholders (`--tracklist-format`)
|
||||
|
||||
| Placeholder | Meaning |
|
||||
| ----------- | --------------------------------------------------- |
|
||||
| `%ts` | **Timestamp** – required (`00:00` or `00:00-01:30`) |
|
||||
| `%tn` | Track name |
|
||||
| `%an` | Author/artist |
|
||||
| `%al` | Album |
|
||||
| `%date` | Date/year |
|
||||
| `%ext` | File extension |
|
||||
|
||||
**Default:** `%ts %tn - %an`
|
||||
|
||||
### Output Template Placeholders (`--output-template`)
|
||||
|
||||
| Placeholder | Meaning |
|
||||
| ----------- | -------------------------------------- |
|
||||
| `%tn` | Track name |
|
||||
| `%an` | Author/artist |
|
||||
| `%al` | Album |
|
||||
| `%date` | Date/year |
|
||||
| `%ext` | File extension (without leading dot) |
|
||||
| `%num` | Track number (zero‑padded, e.g., `01`) |
|
||||
|
||||
**Default:** `%an-%tn.%ext`
|
||||
|
||||
---
|
||||
|
||||
## 💡 Examples
|
||||
|
||||
### Custom tracklist format
|
||||
|
||||
If your tracklist uses `artist - title [time]`:
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt \
|
||||
--tracklist-format "%an - %tn [%ts]"
|
||||
```
|
||||
|
||||
### Custom output filenames
|
||||
|
||||
Name files as `01 - Artist - Song.mp3`:
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt \
|
||||
--output-template "%num - %an - %tn.%ext"
|
||||
```
|
||||
|
||||
### Override album and comment
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt \
|
||||
--album "Greatest Hits" \
|
||||
--comment "Live recording"
|
||||
```
|
||||
|
||||
### Merge multiple comments from input file
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt \
|
||||
--merge-comments \
|
||||
--comment-separator " | "
|
||||
```
|
||||
|
||||
### Delete original file after splitting
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt --delete-original
|
||||
```
|
||||
|
||||
### Dry‑run to preview parsing
|
||||
|
||||
```bash
|
||||
audio_splitter input.flac tracks.txt --dry-run
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Parsed tracklist:
|
||||
------------------------------------------------------------
|
||||
ts | tn | an
|
||||
------------------------------------------------------------
|
||||
1 | 00:00 | Intro |
|
||||
2 | 01:30 | Song One | Artist A
|
||||
3 | 04:20-06:45 | Another Song | Artist B
|
||||
...
|
||||
------------------------------------------------------------
|
||||
Dry‑run complete. No files were created.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker
|
||||
|
||||
You can run `audio_splitter` in a Docker container without installing Python or FFmpeg on your host.
|
||||
|
||||
### Pull the Image (Optional)
|
||||
|
||||
```bash
|
||||
docker pull yourusername/audio_splitter:latest
|
||||
```
|
||||
|
||||
### Build the Image Locally
|
||||
|
||||
```bash
|
||||
docker build -t audio_splitter .
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
**You must mount your working directory to `/data` inside the container.**
|
||||
The container will automatically adjust permissions so that you can read input files and write output files.
|
||||
|
||||
Simply provide the arguments as you would to the `audio_splitter` command:
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt [OPTIONS]
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
**Basic split:**
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt
|
||||
```
|
||||
|
||||
**With custom options:**
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt --album "Greatest Hits" --format mp3 --number-tracks
|
||||
```
|
||||
|
||||
**Dry‑run:**
|
||||
|
||||
```bash
|
||||
docker run --rm -v $(pwd):/data audio_splitter /data/input.mp3 /data/tracks.txt --dry-run
|
||||
```
|
||||
|
||||
**Help:**
|
||||
|
||||
```bash
|
||||
docker run --rm audio_splitter --help
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
All output files are written to the mounted directory on your host (under the default `input_splits/` subdirectory, or any custom `--output-dir` you specify).
|
||||
|
||||
### Permission Handling
|
||||
|
||||
The container automatically adjusts ownership of the mounted `/data` directory so that the container user can read and write files there. No `--user` or `:z` flags are required.
|
||||
|
||||
---
|
||||
|
||||
## 📂 Project Structure
|
||||
|
||||
```
|
||||
audio_splitter/
|
||||
├── __init__.py # Package initialisation
|
||||
├── constants.py # Global constants (FORMAT_INFO, DEFAULT_BAD_CHARS)
|
||||
├── utils.py # Generic helpers (timestamps, string manipulation)
|
||||
├── tracklist.py # Tracklist parsing with custom formats
|
||||
├── ffmpeg.py # FFmpeg/FFprobe interactions and command building
|
||||
├── timestamp.py # Timestamp parsing and resolution
|
||||
├── filename.py # Output filename generation
|
||||
├── metadata.py # Metadata selection and building
|
||||
├── formats.py # Container format decision and validation
|
||||
├── core.py # Main orchestration logic
|
||||
├── main.py # Command‑line interface
|
||||
├── docker-entrypoint.sh # Docker entrypoint script
|
||||
├── Dockerfile # Docker image definition
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
|
||||
@@ -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
@@ -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': []}
|
||||
|
||||
|
||||
@@ -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: User‑requested format (or None).
|
||||
transcode_audio: Audio codec to transcode to (or None).
|
||||
transcode_audio: Audio codec to transcode to (or None) (unused in this function).
|
||||
input_file: Path to the input file (optional, used to detect container and codec).
|
||||
|
||||
Returns:
|
||||
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
|
||||
|
||||
# Audio‑only: choose based on the current audio codec.
|
||||
# Fallback: legacy behavior
|
||||
if stream_info.get('has_video') or stream_info.get('has_subtitle'):
|
||||
return 'matroska'
|
||||
audio_codec = stream_info.get('audio_codec', '')
|
||||
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."
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# ------------------------------------------------------------------------------
|
||||
# HTTPS Server Block (TLS Termination)
|
||||
# ------------------------------------------------------------------------------
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name your.domain.tld;
|
||||
ssl_certificate path;
|
||||
ssl_certificate_key path;
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Security Headers
|
||||
# --------------------------------------------------------------------------
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Proxy to Frontend Container (everything goes through it)
|
||||
# --------------------------------------------------------------------------
|
||||
location / {
|
||||
# Forward all traffic to the frontend container
|
||||
proxy_pass http://127.0.0.1:5173; # <-- Frontend host port (adjust if needed)
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Headers for correct client IP and protocol forwarding
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# The frontend container handles WebSocket upgrades internally,
|
||||
# but we still need to pass the upgrade headers through.
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
# Increase timeouts for long-running operations
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
|
||||
# Allow large file uploads (matches client_max_body_size in frontend NGINX)
|
||||
client_max_body_size 500M;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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:
|
||||
@@ -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
|
||||
@@ -0,0 +1,18 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
.venv
|
||||
venv
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,4 @@
|
||||
MAX_UPLOAD_SIZE_MB=500
|
||||
TEMP_DIR=/tmp/audio_splitter_web
|
||||
CLEANUP_AFTER_SECONDS=3600
|
||||
ALLOW_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Install FFmpeg, system dependencies, and gosu from APT
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ffmpeg \
|
||||
ca-certificates \
|
||||
gosu \
|
||||
&& \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set Python environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements and install Python dependencies
|
||||
COPY web/backend/requirements-web.txt .
|
||||
RUN pip install --no-cache-dir -r requirements-web.txt
|
||||
|
||||
# Copy backend code
|
||||
COPY web/backend /app/backend
|
||||
|
||||
# Copy and install audio_splitter package
|
||||
COPY audio_splitter /app/audio_splitter
|
||||
COPY setup.py pyproject.toml README.md /app/
|
||||
RUN pip install --no-cache-dir /app
|
||||
|
||||
# Create a non-root user
|
||||
RUN addgroup --system --gid 1000 appgroup && \
|
||||
adduser --system --uid 1000 --ingroup appgroup appuser && \
|
||||
chown -R appuser:appgroup /app
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY web/backend/docker-entrypoint.sh /docker-entrypoint.sh
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/docker-entrypoint.sh"]
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
@@ -0,0 +1,8 @@
|
||||
# From the web/ directory
|
||||
cd web
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements-web.txt
|
||||
|
||||
# Run the server (note the module path: backend.main)
|
||||
uvicorn backend.main:app --reload --port 8000
|
||||
@@ -0,0 +1 @@
|
||||
"""Audio Splitter Web Backend"""
|
||||
@@ -0,0 +1,4 @@
|
||||
"""API route handlers."""
|
||||
|
||||
from . import upload, split, status, download
|
||||
# websocket is imported directly in main.py to avoid circular import
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Download endpoint for split results."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from zipfile import ZipFile
|
||||
import os
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.task_manager import task_manager
|
||||
from backend.models.response import TaskStatus
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["download"])
|
||||
|
||||
|
||||
def _prepare_download(task_id: str):
|
||||
"""Common logic to prepare and return the ZIP file."""
|
||||
if not task_manager.has_task(task_id):
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
status = task_manager.get_status(task_id)
|
||||
if status["status"] != TaskStatus.DONE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Task {task_id} is not complete. Current status: {status['status']}"
|
||||
)
|
||||
|
||||
output_dir = settings.temp_dir / task_id / "output"
|
||||
if not output_dir.exists() or not any(output_dir.iterdir()):
|
||||
raise HTTPException(status_code=404, detail="No output files found")
|
||||
|
||||
zip_path = settings.temp_dir / task_id / "splits.zip"
|
||||
with ZipFile(zip_path, "w") as zipf:
|
||||
for file_path in output_dir.iterdir():
|
||||
if file_path.is_file():
|
||||
zipf.write(file_path, arcname=file_path.name)
|
||||
|
||||
return zip_path
|
||||
|
||||
|
||||
@router.get("/download/{task_id}")
|
||||
async def download_results(task_id: str):
|
||||
zip_path = _prepare_download(task_id)
|
||||
return FileResponse(
|
||||
path=zip_path,
|
||||
media_type="application/zip",
|
||||
filename=f"{task_id}_splits.zip",
|
||||
headers={"Content-Disposition": f"attachment; filename={task_id}_splits.zip"}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/download/{task_id}/splits.zip")
|
||||
async def download_results_as_zip(task_id: str):
|
||||
"""Alias endpoint that provides a .zip suffix for easier curl usage."""
|
||||
zip_path = _prepare_download(task_id)
|
||||
return FileResponse(
|
||||
path=zip_path,
|
||||
media_type="application/zip",
|
||||
filename="splits.zip",
|
||||
headers={"Content-Disposition": "attachment; filename=splits.zip"}
|
||||
)
|
||||
|
||||
|
||||
# NEW: Endpoint for downloading individual tracks
|
||||
@router.get("/download/{task_id}/{filename}")
|
||||
async def download_single_track(task_id: str, filename: str):
|
||||
"""
|
||||
Download a single track file from the output directory.
|
||||
"""
|
||||
if not task_manager.has_task(task_id):
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
status = task_manager.get_status(task_id)
|
||||
if status["status"] != TaskStatus.DONE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Task {task_id} is not complete. Current status: {status['status']}"
|
||||
)
|
||||
|
||||
output_dir = settings.temp_dir / task_id / "output"
|
||||
file_path = output_dir / filename
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File '{filename}' not found")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Endpoint to expose format information to the frontend."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.constants import FORMAT_INFO
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["formats"])
|
||||
|
||||
|
||||
@router.get("/formats")
|
||||
async def get_formats():
|
||||
"""
|
||||
Return the list of supported container formats with their properties.
|
||||
"""
|
||||
return {
|
||||
"formats": [
|
||||
{
|
||||
"name": name,
|
||||
"ffmpeg": info["ffmpeg"],
|
||||
"extension": info["ext"],
|
||||
"audio_only": info["audio_only"],
|
||||
}
|
||||
for name, info in FORMAT_INFO.items()
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Endpoint to retrieve stream information for an uploaded file."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from backend.config import settings
|
||||
from backend.services.file_manager import FileManager
|
||||
from backend.services.task_manager import task_manager
|
||||
from backend.ffmpeg import get_stream_info
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["info"])
|
||||
|
||||
|
||||
@router.get("/info/{task_id}")
|
||||
async def get_task_info(task_id: str):
|
||||
"""
|
||||
Return stream information (has_audio, has_video, has_subtitle) for the uploaded file.
|
||||
"""
|
||||
if not task_manager.has_task(task_id):
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
input_path = FileManager.get_input_path(task_id)
|
||||
if not input_path or not input_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Input file not found")
|
||||
|
||||
try:
|
||||
info = get_stream_info(str(input_path))
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"has_audio": info["has_audio"],
|
||||
"has_video": info["has_video"],
|
||||
"has_subtitle": info["has_subtitle"],
|
||||
"audio_codec": info["audio_codec"],
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Split task endpoint."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
|
||||
from backend.models.request import SplitRequest
|
||||
from backend.models.response import SplitResponse, TaskStatus
|
||||
from backend.services.splitter import run_split_task
|
||||
from backend.services.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["split"])
|
||||
|
||||
|
||||
@router.post("/split", response_model=SplitResponse)
|
||||
async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
|
||||
task_id = request.task_id
|
||||
|
||||
if not task_manager.has_task(task_id):
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
status = task_manager.get_status(task_id)
|
||||
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Task {task_id} is already {status['status']}"
|
||||
)
|
||||
|
||||
task_manager.update_task(
|
||||
task_id,
|
||||
status=TaskStatus.PROCESSING,
|
||||
progress=0,
|
||||
message="Preparing to split..."
|
||||
)
|
||||
|
||||
background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options)
|
||||
|
||||
return SplitResponse(
|
||||
task_id=task_id,
|
||||
status=TaskStatus.PROCESSING
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Status query endpoint."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from backend.models.response import StatusResponse
|
||||
from backend.services.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["status"])
|
||||
|
||||
|
||||
@router.get("/status/{task_id}", response_model=StatusResponse)
|
||||
async def get_status(task_id: str):
|
||||
if not task_manager.has_task(task_id):
|
||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||
|
||||
status = task_manager.get_status(task_id)
|
||||
|
||||
return StatusResponse(
|
||||
task_id=task_id,
|
||||
status=status["status"],
|
||||
progress=status["progress"],
|
||||
message=status["message"],
|
||||
error=status.get("error"),
|
||||
tracks=status.get("tracks", [])
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""File upload endpoint."""
|
||||
|
||||
import uuid
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException
|
||||
|
||||
from backend.config import settings
|
||||
from backend.models.response import UploadResponse
|
||||
from backend.services.task_manager import task_manager
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["upload"])
|
||||
|
||||
ALLOWED_EXTENSIONS = {
|
||||
".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus",
|
||||
".aac", ".wma", ".aiff", ".alac", ".ac3"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/upload", response_model=UploadResponse)
|
||||
async def upload_file(file: UploadFile = File(...)):
|
||||
extension = Path(file.filename).suffix.lower()
|
||||
if extension not in ALLOWED_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported file format. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
|
||||
)
|
||||
|
||||
task_id = str(uuid.uuid4())[:8]
|
||||
task_dir = settings.temp_dir / task_id
|
||||
task_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
input_path = task_dir / f"input{extension}"
|
||||
try:
|
||||
with open(input_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
except Exception as e:
|
||||
shutil.rmtree(task_dir, ignore_errors=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
|
||||
|
||||
file_size = input_path.stat().st_size
|
||||
max_size_bytes = settings.max_upload_size_mb * 1024 * 1024
|
||||
if file_size > max_size_bytes:
|
||||
shutil.rmtree(task_dir, ignore_errors=True)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File too large. Maximum size: {settings.max_upload_size_mb} MB"
|
||||
)
|
||||
|
||||
# Create task entry in the task manager
|
||||
task_manager.create_task(task_id, file.filename, file_size)
|
||||
|
||||
return UploadResponse(
|
||||
task_id=task_id,
|
||||
filename=file.filename,
|
||||
size=file_size
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
"""WebSocket endpoint for real‑time progress updates."""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from backend.services.task_manager import task_manager
|
||||
from backend.services.progress_publisher import register_connection, unregister_connection
|
||||
|
||||
router = APIRouter(tags=["websocket"])
|
||||
|
||||
|
||||
@router.websocket("/ws/{task_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, task_id: str):
|
||||
await websocket.accept()
|
||||
|
||||
register_connection(task_id, websocket)
|
||||
|
||||
try:
|
||||
# Send initial status
|
||||
status = task_manager.get_status(task_id)
|
||||
await websocket.send_json({
|
||||
"type": "status",
|
||||
"data": status
|
||||
})
|
||||
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
try:
|
||||
message = json.loads(data)
|
||||
if message.get("type") == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
elif message.get("type") == "get_status":
|
||||
status = task_manager.get_status(task_id)
|
||||
await websocket.send_json({
|
||||
"type": "status",
|
||||
"data": status
|
||||
})
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except WebSocketDisconnect:
|
||||
unregister_connection(task_id, websocket)
|
||||
except Exception as e:
|
||||
unregister_connection(task_id, websocket)
|
||||
print(f"WebSocket error: {e}")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Configuration settings for the web backend."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
# Server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
debug: bool = False
|
||||
|
||||
# File handling
|
||||
max_upload_size_mb: int = 500
|
||||
temp_dir: Path = Path("/tmp/audio_splitter_web")
|
||||
cleanup_after_seconds: int = 3600 # 1 hour
|
||||
|
||||
# CORS
|
||||
allow_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_file_encoding = "utf-8"
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
# Ensure temp directory exists
|
||||
settings.temp_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,12 @@
|
||||
FORMAT_INFO = {
|
||||
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
|
||||
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
|
||||
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
|
||||
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
|
||||
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
|
||||
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
|
||||
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
|
||||
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
|
||||
}
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Detect if we are running as root (default)
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
# Ensure the temp directory exists and set ownership
|
||||
if [ -d "/tmp/audio_splitter_web" ]; then
|
||||
echo "Setting ownership of /tmp/audio_splitter_web to appuser:appgroup"
|
||||
chown -R appuser:appgroup /tmp/audio_splitter_web
|
||||
else
|
||||
echo "Creating /tmp/audio_splitter_web and setting ownership"
|
||||
mkdir -p /tmp/audio_splitter_web
|
||||
chown -R appuser:appgroup /tmp/audio_splitter_web
|
||||
fi
|
||||
|
||||
# Drop privileges and run uvicorn using gosu
|
||||
exec gosu appuser uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
||||
else
|
||||
# If not root, just run directly
|
||||
exec uvicorn backend.main:app --host 0.0.0.0 --port 8000
|
||||
fi
|
||||
@@ -0,0 +1,79 @@
|
||||
"""FFmpeg/FFprobe interaction utilities for the web backend."""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
|
||||
def get_stream_info(input_file: str):
|
||||
"""
|
||||
Retrieve stream information (audio, video, subtitle presence) from a media file.
|
||||
Returns a dict with keys: has_audio, has_video, has_subtitle, audio_codec.
|
||||
"""
|
||||
# Get audio codec (if any)
|
||||
audio_codec = None
|
||||
try:
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-select_streams', 'a:0',
|
||||
'-show_entries', 'stream=codec_name',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
codec = result.stdout.strip().lower()
|
||||
if codec:
|
||||
audio_codec = codec
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check for video stream
|
||||
has_video = False
|
||||
try:
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-select_streams', 'v',
|
||||
'-show_entries', 'stream=codec_type',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
has_video = bool(result.stdout.strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check for subtitle stream
|
||||
has_subtitle = False
|
||||
try:
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-select_streams', 's',
|
||||
'-show_entries', 'stream=codec_type',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
has_subtitle = bool(result.stdout.strip())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'has_audio': audio_codec is not None,
|
||||
'has_video': has_video,
|
||||
'has_subtitle': has_subtitle,
|
||||
'audio_codec': audio_codec,
|
||||
}
|
||||
|
||||
|
||||
def get_audio_duration(input_file: str) -> float:
|
||||
"""Get the duration of the audio file in seconds."""
|
||||
cmd = [
|
||||
'ffprobe', '-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
input_file
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||
try:
|
||||
return float(result.stdout.strip())
|
||||
except ValueError:
|
||||
return 0.0
|
||||
@@ -0,0 +1,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"}
|
||||
@@ -0,0 +1 @@
|
||||
"""Pydantic models for request/response validation."""
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Request models for API endpoints."""
|
||||
|
||||
from typing import Optional, List
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
|
||||
class TracklistEntry(BaseModel):
|
||||
"""A single track entry from the tracklist."""
|
||||
|
||||
ts: str = Field(..., description="Timestamp (e.g., '00:00' or '00:00-01:30')")
|
||||
tn: Optional[str] = Field("", description="Track name")
|
||||
an: Optional[str] = Field("", description="Author/artist")
|
||||
al: Optional[str] = Field("", description="Album")
|
||||
date: Optional[str] = Field("", description="Date/year")
|
||||
ext: Optional[str] = Field("", description="File extension")
|
||||
|
||||
@validator("ts")
|
||||
def validate_timestamp(cls, v: str) -> str:
|
||||
"""Basic timestamp validation (format and range)."""
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("Timestamp cannot be empty")
|
||||
|
||||
# Check for range format (start-end)
|
||||
if "-" in v:
|
||||
parts = v.split("-", 1)
|
||||
start = parts[0].strip()
|
||||
end = parts[1].strip()
|
||||
if not start or not end:
|
||||
raise ValueError("Invalid range format. Expected 'start-end'")
|
||||
# Validate each part with the same logic
|
||||
for ts in [start, end]:
|
||||
cls._validate_single_timestamp(ts)
|
||||
else:
|
||||
cls._validate_single_timestamp(v)
|
||||
|
||||
return v
|
||||
|
||||
@staticmethod
|
||||
def _validate_single_timestamp(ts: str) -> None:
|
||||
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
|
||||
parts = ts.split(":")
|
||||
if len(parts) not in (2, 3):
|
||||
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS")
|
||||
try:
|
||||
for p in parts:
|
||||
int(p)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid timestamp: {ts}. Must contain only numbers.")
|
||||
|
||||
|
||||
class SplitRequest(BaseModel):
|
||||
"""Request model for the split endpoint."""
|
||||
|
||||
task_id: str = Field(..., description="Task ID from upload")
|
||||
tracklist: List[TracklistEntry] = Field(..., description="List of tracks")
|
||||
options: dict = Field(default_factory=dict, description="All CLI options")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Response models for API endpoints."""
|
||||
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
DONE = "done"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
class TrackInfo(BaseModel):
|
||||
filename: str
|
||||
size: int # bytes
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
task_id: str
|
||||
filename: str
|
||||
size: int
|
||||
|
||||
|
||||
class SplitResponse(BaseModel):
|
||||
task_id: str
|
||||
status: TaskStatus
|
||||
|
||||
|
||||
class StatusResponse(BaseModel):
|
||||
task_id: str
|
||||
status: TaskStatus
|
||||
progress: int = Field(0, ge=0, le=100)
|
||||
message: str = ""
|
||||
error: Optional[str] = None
|
||||
tracks: List[TrackInfo] = []
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
python-multipart>=0.0.6
|
||||
aiofiles>=23.2.0
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
websockets>=12.0
|
||||
@@ -0,0 +1 @@
|
||||
"""Business logic services."""
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Temporary file management for web operations."""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from backend.config import settings
|
||||
|
||||
|
||||
class FileManager:
|
||||
@staticmethod
|
||||
def get_task_dir(task_id: str) -> Path:
|
||||
return settings.temp_dir / task_id
|
||||
|
||||
@staticmethod
|
||||
def get_input_path(task_id: str) -> Path:
|
||||
task_dir = FileManager.get_task_dir(task_id)
|
||||
for f in task_dir.iterdir():
|
||||
if f.name.startswith("input."):
|
||||
return f
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_output_dir(task_id: str) -> Path:
|
||||
return FileManager.get_task_dir(task_id) / "output"
|
||||
|
||||
@staticmethod
|
||||
def ensure_output_dir(task_id: str) -> Path:
|
||||
output_dir = FileManager.get_output_dir(task_id)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
return output_dir
|
||||
|
||||
@staticmethod
|
||||
def cleanup_task(task_id: str) -> None:
|
||||
task_dir = FileManager.get_task_dir(task_id)
|
||||
if task_dir.exists():
|
||||
shutil.rmtree(task_dir, ignore_errors=True)
|
||||
|
||||
@staticmethod
|
||||
def get_output_files(task_id: str) -> list:
|
||||
output_dir = FileManager.get_output_dir(task_id)
|
||||
if not output_dir.exists():
|
||||
return []
|
||||
files = []
|
||||
for f in output_dir.iterdir():
|
||||
if f.is_file():
|
||||
files.append({"filename": f.name, "size": f.stat().st_size})
|
||||
return files
|
||||
@@ -0,0 +1,66 @@
|
||||
"""WebSocket progress publisher – decouples task manager from WebSocket."""
|
||||
|
||||
import asyncio
|
||||
from typing import Dict, Set
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
# This will be set by main.py during startup
|
||||
MAIN_LOOP = None
|
||||
|
||||
active_connections: Dict[str, Set[WebSocket]] = {}
|
||||
|
||||
|
||||
def publish_progress(task_id: str, progress: int, message: str, status: str = "processing"):
|
||||
"""
|
||||
Publish progress update to all connected WebSocket clients for a task.
|
||||
"""
|
||||
if task_id not in active_connections:
|
||||
return
|
||||
|
||||
data = {
|
||||
"type": "progress",
|
||||
"data": {
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"message": message
|
||||
}
|
||||
}
|
||||
|
||||
to_remove = set()
|
||||
# Get the loop to use: either the stored one or try to get the current loop
|
||||
loop = MAIN_LOOP
|
||||
if loop is None:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop, fallback to default event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
for websocket in active_connections.get(task_id, set()):
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(websocket.send_json(data), loop)
|
||||
except Exception:
|
||||
to_remove.add(websocket)
|
||||
|
||||
# Clean up disconnected clients
|
||||
for websocket in to_remove:
|
||||
active_connections[task_id].discard(websocket)
|
||||
if task_id in active_connections and not active_connections[task_id]:
|
||||
del active_connections[task_id]
|
||||
|
||||
|
||||
def register_connection(task_id: str, websocket: WebSocket):
|
||||
"""Register a WebSocket connection for a task."""
|
||||
if task_id not in active_connections:
|
||||
active_connections[task_id] = set()
|
||||
active_connections[task_id].add(websocket)
|
||||
|
||||
|
||||
def unregister_connection(task_id: str, websocket: WebSocket):
|
||||
"""Unregister a WebSocket connection for a task."""
|
||||
if task_id in active_connections:
|
||||
active_connections[task_id].discard(websocket)
|
||||
if not active_connections[task_id]:
|
||||
del active_connections[task_id]
|
||||
@@ -0,0 +1,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)
|
||||
)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""In-memory task state management."""
|
||||
|
||||
from typing import Dict, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
import threading
|
||||
import time
|
||||
|
||||
from backend.config import settings
|
||||
from backend.models.response import TaskStatus, TrackInfo
|
||||
from backend.services.progress_publisher import publish_progress
|
||||
|
||||
|
||||
class TaskManager:
|
||||
def __init__(self):
|
||||
self._tasks: Dict[str, dict] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def create_task(self, task_id: str, filename: str, file_size: int) -> None:
|
||||
with self._lock:
|
||||
self._tasks[task_id] = {
|
||||
"task_id": task_id,
|
||||
"filename": filename,
|
||||
"file_size": file_size,
|
||||
"status": TaskStatus.PENDING,
|
||||
"progress": 0,
|
||||
"message": "Upload complete",
|
||||
"error": None,
|
||||
"tracks": [],
|
||||
"created_at": datetime.now(),
|
||||
"updated_at": datetime.now()
|
||||
}
|
||||
|
||||
def has_task(self, task_id: str) -> bool:
|
||||
return task_id in self._tasks
|
||||
|
||||
def get_status(self, task_id: str) -> dict:
|
||||
"""Get task status with datetime objects converted to ISO format."""
|
||||
with self._lock:
|
||||
if task_id not in self._tasks:
|
||||
return {}
|
||||
# Return a copy with datetime objects converted to ISO strings
|
||||
status = self._tasks[task_id].copy()
|
||||
return self._prepare_status_for_serialization(status)
|
||||
|
||||
def _prepare_status_for_serialization(self, status: dict) -> dict:
|
||||
"""Convert datetime objects to ISO format strings for JSON serialization."""
|
||||
serializable = {}
|
||||
for key, value in status.items():
|
||||
if isinstance(value, datetime):
|
||||
serializable[key] = value.isoformat()
|
||||
elif isinstance(value, list):
|
||||
# Handle lists of objects (e.g., tracks)
|
||||
serializable[key] = [
|
||||
{k: v.isoformat() if isinstance(v, datetime) else v for k, v in item.items()}
|
||||
if isinstance(item, dict)
|
||||
else item
|
||||
for item in value
|
||||
]
|
||||
elif isinstance(value, dict):
|
||||
# Recursively handle nested dicts
|
||||
serializable[key] = {
|
||||
k: v.isoformat() if isinstance(v, datetime) else v
|
||||
for k, v in value.items()
|
||||
}
|
||||
else:
|
||||
serializable[key] = value
|
||||
return serializable
|
||||
|
||||
def update_task(self, task_id: str, **kwargs) -> None:
|
||||
with self._lock:
|
||||
if task_id in self._tasks:
|
||||
self._tasks[task_id].update(kwargs)
|
||||
self._tasks[task_id]["updated_at"] = datetime.now()
|
||||
|
||||
def update_task_with_progress(self, task_id: str, progress: int, message: str,
|
||||
status: str = None, error: str = None) -> None:
|
||||
update_kwargs = {
|
||||
"progress": progress,
|
||||
"message": message
|
||||
}
|
||||
if status:
|
||||
update_kwargs["status"] = status
|
||||
if error is not None:
|
||||
update_kwargs["error"] = error
|
||||
|
||||
self.update_task(task_id, **update_kwargs)
|
||||
|
||||
# Publish progress via WebSocket
|
||||
publish_progress(task_id, progress, message, status or "processing")
|
||||
|
||||
def cleanup_old_tasks(self) -> None:
|
||||
now = datetime.now()
|
||||
timeout = timedelta(seconds=settings.cleanup_after_seconds)
|
||||
with self._lock:
|
||||
to_delete = []
|
||||
for task_id, data in self._tasks.items():
|
||||
if now - data["created_at"] > timeout:
|
||||
to_delete.append(task_id)
|
||||
for task_id in to_delete:
|
||||
self._delete_task_files(task_id)
|
||||
del self._tasks[task_id]
|
||||
|
||||
def _delete_task_files(self, task_id: str) -> None:
|
||||
import shutil
|
||||
task_dir = settings.temp_dir / task_id
|
||||
if task_dir.exists():
|
||||
shutil.rmtree(task_dir, ignore_errors=True)
|
||||
|
||||
|
||||
task_manager = TaskManager()
|
||||
@@ -0,0 +1 @@
|
||||
"""Utility functions for the web backend."""
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Input validation utilities."""
|
||||
|
||||
import re
|
||||
from typing import List, Tuple
|
||||
|
||||
from ..models.request import TracklistEntry
|
||||
|
||||
|
||||
def validate_tracklist(tracklist: List[TracklistEntry]) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate a tracklist and return any errors.
|
||||
|
||||
Returns:
|
||||
(is_valid, error_messages)
|
||||
"""
|
||||
errors = []
|
||||
for idx, entry in enumerate(tracklist, 1):
|
||||
# Each entry must have a timestamp
|
||||
if not entry.ts or not entry.ts.strip():
|
||||
errors.append(f"Line {idx}: Missing timestamp (%ts)")
|
||||
# Additional validation could be added here
|
||||
|
||||
return len(errors) == 0, errors
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
Dockerfile
|
||||
Dockerfile.dev
|
||||
.dockerignore
|
||||
nginx.conf
|
||||
@@ -0,0 +1,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;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Audio Splitter</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
client_max_body_size 500M;
|
||||
# Root directory for static files
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve React app
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Proxy WebSocket requests to backend
|
||||
location /ws/ {
|
||||
proxy_pass http://backend:8000/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
Generated
+4239
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from 'react'
|
||||
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material'
|
||||
import { Box, Grid, Button, CircularProgress, Typography } from '@mui/material'
|
||||
import { PlayArrow } from '@mui/icons-material'
|
||||
import { Layout } from './components/Layout'
|
||||
import { UploadZone } from './components/UploadZone'
|
||||
import { TracklistEditor } from './components/TracklistEditor'
|
||||
import { OptionsPanel } from './components/OptionsPanel'
|
||||
import { ProgressDisplay } from './components/ProgressDisplay'
|
||||
import { DownloadSection } from './components/DownloadSection'
|
||||
import { useUploadStore } from './stores/uploadStore'
|
||||
import { useTracklistStore } from './stores/tracklistStore'
|
||||
import { useOptionsStore } from './stores/optionsStore'
|
||||
import { useTaskStore } from './stores/taskStore'
|
||||
import { useUIStore } from './stores/uiStore'
|
||||
import { useValidationStore } from './stores/validationStore'
|
||||
import { useWebSocket } from './hooks/useWebSocket'
|
||||
import { startSplit } from './api/client'
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { theme } = useUIStore()
|
||||
const { taskId } = useUploadStore()
|
||||
const { entries, isValid } = useTracklistStore()
|
||||
const { options } = useOptionsStore()
|
||||
const {
|
||||
isProcessing,
|
||||
setTaskId,
|
||||
setError,
|
||||
setIsProcessing,
|
||||
addLog,
|
||||
reset,
|
||||
} = useTaskStore()
|
||||
const { formatError } = useValidationStore()
|
||||
|
||||
useWebSocket(taskId && isProcessing ? taskId : null)
|
||||
|
||||
const handleSplit = async () => {
|
||||
if (!taskId) {
|
||||
alert('Please upload a file first')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
alert('Tracklist has errors. Please fix them before splitting.')
|
||||
return
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
alert('Tracklist is empty')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setTaskId(taskId)
|
||||
setIsProcessing(true)
|
||||
addLog('🚀 Starting split...')
|
||||
|
||||
const response = await startSplit(taskId, entries, options)
|
||||
addLog(`✅ Split task started (ID: ${response.task_id})`)
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.detail || error.message || 'Failed to start split')
|
||||
addLog(`❌ Error: ${error.response?.data?.detail || error.message || 'Failed to start split'}`)
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
reset()
|
||||
}
|
||||
|
||||
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
theme={createTheme({
|
||||
palette: {
|
||||
mode: theme,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<CssBaseline />
|
||||
<Layout>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<UploadZone />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TracklistEditor />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<OptionsPanel />
|
||||
</Grid>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={isProcessing ? <CircularProgress size={20} color="inherit" /> : <PlayArrow />}
|
||||
onClick={handleSplit}
|
||||
disabled={isSplitDisabled}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Split'}
|
||||
</Button>
|
||||
<Button variant="outlined" color="secondary" onClick={handleReset} disabled={isProcessing}>
|
||||
Reset
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{formatError && (
|
||||
<Box sx={{ mb: 2, p: 2, bgcolor: 'warning.light', borderRadius: 1 }}>
|
||||
<Typography color="warning.dark" variant="body2">
|
||||
⚠️ {formatError}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ProgressDisplay />
|
||||
<DownloadSection />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Layout>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,65 @@
|
||||
import axios from 'axios'
|
||||
import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await api.post('/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const startSplit = async (
|
||||
task_id: string,
|
||||
tracklist: TracklistEntry[],
|
||||
options: SplitOptions
|
||||
): Promise<{ task_id: string; status: string }> => {
|
||||
const response = await api.post('/split', {
|
||||
task_id,
|
||||
tracklist,
|
||||
options,
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getStatus = async (task_id: string): Promise<TaskStatus> => {
|
||||
const response = await api.get(`/status/${task_id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getDownloadUrl = (task_id: string): string => {
|
||||
return `/api/download/${task_id}`
|
||||
}
|
||||
|
||||
export const getDownloadZipUrl = (task_id: string): string => {
|
||||
return `/api/download/${task_id}/splits.zip`
|
||||
}
|
||||
|
||||
// New functions for format validation feature
|
||||
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
|
||||
const response = await api.get('/formats')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getTaskInfo = async (task_id: string): Promise<{
|
||||
task_id: string
|
||||
has_audio: boolean
|
||||
has_video: boolean
|
||||
has_subtitle: boolean
|
||||
audio_codec: string | null
|
||||
}> => {
|
||||
const response = await api.get(`/info/${task_id}`)
|
||||
return response.data
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import { Paper, Typography, Button, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material'
|
||||
import { Download, FolderZip } from '@mui/icons-material'
|
||||
import { useTaskStore } from '../stores/taskStore'
|
||||
import { getDownloadZipUrl } from '../api/client'
|
||||
import { formatFileSize } from '../utils/formatters'
|
||||
|
||||
export const DownloadSection: React.FC = () => {
|
||||
const { taskId, tracks, status } = useTaskStore()
|
||||
|
||||
console.log('[DownloadSection] Rendering:', { status, tracks, taskId })
|
||||
|
||||
// Check if we should show the download section
|
||||
if (status !== 'done' || !tracks || tracks.length === 0 || !taskId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If we get here, we have tracks
|
||||
console.log('[DownloadSection] Showing tracks:', tracks)
|
||||
|
||||
const handleDownloadZip = () => {
|
||||
const url = getDownloadZipUrl(taskId)
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
const handleDownloadTrack = (filename: string) => {
|
||||
const url = `/api/download/${taskId}/${filename}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3, mt: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
📥 Download Results
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<FolderZip />}
|
||||
onClick={handleDownloadZip}
|
||||
sx={{ mb: 2 }}
|
||||
fullWidth
|
||||
>
|
||||
Download All as ZIP
|
||||
</Button>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||
Individual Tracks
|
||||
</Typography>
|
||||
|
||||
<List dense>
|
||||
{tracks.map((track, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
secondaryAction={
|
||||
<IconButton edge="end" onClick={() => handleDownloadTrack(track.filename)} size="small">
|
||||
<Download />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={track.filename}
|
||||
secondary={formatFileSize(track.size)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react'
|
||||
import { AppBar, Toolbar, Typography, IconButton, Box, Container, Badge } from '@mui/material'
|
||||
import { Brightness4, Brightness7, FiberManualRecord } from '@mui/icons-material'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
const { theme, toggleTheme, wsConnected } = useUIStore()
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<AppBar position="static" color="default" elevation={1}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
|
||||
🎵 Audio Splitter
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Badge
|
||||
color={wsConnected ? 'success' : 'error'}
|
||||
variant="dot"
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
<FiberManualRecord
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: wsConnected ? 'green' : 'red',
|
||||
visibility: 'hidden',
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{wsConnected ? 'Connected' : 'Disconnected'}
|
||||
</Typography>
|
||||
|
||||
<IconButton onClick={toggleTheme} color="inherit">
|
||||
{theme === 'light' ? <Brightness4 /> : <Brightness7 />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Container maxWidth="lg" sx={{ flex: 1, py: 4 }}>
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
<Box component="footer" sx={{ py: 2, textAlign: 'center', borderTop: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Audio Splitter v0.1.0 • Built with ❤️
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
TextField,
|
||||
MenuItem,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Collapse,
|
||||
IconButton,
|
||||
Divider,
|
||||
Alert,
|
||||
} from '@mui/material'
|
||||
import { ExpandMore, ExpandLess } from '@mui/icons-material'
|
||||
import { useOptionsStore } from '../stores/optionsStore'
|
||||
import { useUploadStore } from '../stores/uploadStore'
|
||||
import { useValidationStore } from '../stores/validationStore'
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
|
||||
const [expanded, setExpanded] = React.useState(defaultExpanded)
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
py: 1,
|
||||
}}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<IconButton size="small">{expanded ? <ExpandLess /> : <ExpandMore />}</IconButton>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Collapse in={expanded}>
|
||||
<Box sx={{ pt: 2, pb: 1 }}>{children}</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const OptionsPanel: React.FC = () => {
|
||||
const { options, setOptions } = useOptionsStore()
|
||||
const { hasVideo } = useUploadStore()
|
||||
const { formatError, setFormatError } = useValidationStore()
|
||||
|
||||
// Audio-only formats from backend constants (hardcoded for now)
|
||||
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
|
||||
|
||||
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newFormat = e.target.value
|
||||
setOptions({ format: newFormat })
|
||||
|
||||
// Validate format
|
||||
if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
|
||||
setFormatError(
|
||||
`Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||
)
|
||||
} else {
|
||||
setFormatError(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const checked = e.target.checked
|
||||
setOptions({ drop_video: checked })
|
||||
// Re-validate format
|
||||
if (audioOnlyFormats.includes(options.format) && hasVideo && !checked) {
|
||||
setFormatError(
|
||||
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||
)
|
||||
} else {
|
||||
setFormatError(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate when hasVideo changes (e.g., after upload)
|
||||
useEffect(() => {
|
||||
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
|
||||
const newError = shouldShowError
|
||||
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||
: null
|
||||
|
||||
// Only update if the error state actually changes
|
||||
if (newError !== formatError) {
|
||||
setFormatError(newError)
|
||||
}
|
||||
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
⚙️ Options
|
||||
</Typography>
|
||||
|
||||
{formatError && (
|
||||
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||
{formatError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Output Settings */}
|
||||
<Section title="Output Settings" defaultExpanded>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Format"
|
||||
select
|
||||
value={options.format}
|
||||
onChange={handleFormatChange}
|
||||
fullWidth
|
||||
size="small"
|
||||
error={!!formatError}
|
||||
>
|
||||
<MenuItem value="mp3">MP3</MenuItem>
|
||||
<MenuItem value="m4a">M4A</MenuItem>
|
||||
<MenuItem value="mkv">MKV</MenuItem>
|
||||
<MenuItem value="mp4">MP4</MenuItem>
|
||||
<MenuItem value="ogg">OGG</MenuItem>
|
||||
<MenuItem value="opus">OPUS</MenuItem>
|
||||
<MenuItem value="flac">FLAC</MenuItem>
|
||||
<MenuItem value="wav">WAV</MenuItem>
|
||||
<MenuItem value="aac">AAC</MenuItem>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
label="Transcode to"
|
||||
select
|
||||
value={options.transcode_to || ''}
|
||||
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
||||
fullWidth
|
||||
size="small"
|
||||
>
|
||||
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
||||
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
||||
<MenuItem value="aac">AAC</MenuItem>
|
||||
<MenuItem value="libopus">OPUS</MenuItem>
|
||||
</TextField>
|
||||
|
||||
{hasVideo && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.drop_video}
|
||||
onChange={handleDropVideoChange}
|
||||
/>
|
||||
}
|
||||
label="Drop video streams"
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.drop_subs}
|
||||
onChange={(e) => handleChange('drop_subs', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Drop subtitle streams"
|
||||
/>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Filename Settings */}
|
||||
<Section title="Filename Settings">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Output template"
|
||||
value={options.output_template}
|
||||
onChange={(e) => handleChange('output_template', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText="Placeholders: %tn (track name), %an (author), %al (album), %date, %ext, %num"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.number_tracks}
|
||||
onChange={(e) => handleChange('number_tracks', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Number tracks (01 - )"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.replace_bad_chars}
|
||||
onChange={(e) => handleChange('replace_bad_chars', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Replace bad characters"
|
||||
/>
|
||||
<TextField
|
||||
label="Replacement character"
|
||||
value={options.replacement_char}
|
||||
onChange={(e) => handleChange('replacement_char', e.target.value)}
|
||||
size="small"
|
||||
disabled={!options.replace_bad_chars}
|
||||
/>
|
||||
<TextField
|
||||
label="Bad characters list"
|
||||
value={options.bad_chars}
|
||||
onChange={(e) => handleChange('bad_chars', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
disabled={!options.replace_bad_chars}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.skip_existing}
|
||||
onChange={(e) => handleChange('skip_existing', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Skip existing files"
|
||||
/>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Metadata Settings */}
|
||||
<Section title="Metadata Settings">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Album"
|
||||
value={options.album}
|
||||
onChange={(e) => handleChange('album', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<TextField
|
||||
label="Comment"
|
||||
value={options.comment}
|
||||
onChange={(e) => handleChange('comment', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.no_comment}
|
||||
onChange={(e) => handleChange('no_comment', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="No comment"
|
||||
/>
|
||||
<TextField
|
||||
label="Comment stream index"
|
||||
type="number"
|
||||
value={options.comment_stream ?? ''}
|
||||
onChange={(e) =>
|
||||
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
|
||||
}
|
||||
size="small"
|
||||
disabled={options.no_comment}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={options.merge_comments}
|
||||
onChange={(e) => handleChange('merge_comments', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Merge all comments"
|
||||
disabled={options.no_comment}
|
||||
/>
|
||||
<TextField
|
||||
label="Comment separator"
|
||||
value={options.comment_separator}
|
||||
onChange={(e) => handleChange('comment_separator', e.target.value)}
|
||||
size="small"
|
||||
disabled={!options.merge_comments || options.no_comment}
|
||||
/>
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Tracklist Settings */}
|
||||
<Section title="Tracklist Settings" defaultExpanded>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Tracklist Format"
|
||||
value={options.tracklist_format}
|
||||
onChange={(e) => handleChange('tracklist_format', e.target.value)}
|
||||
fullWidth
|
||||
size="small"
|
||||
helperText="Placeholders: %ts (timestamp), %tn (track name), %an (author), %al (album), %date, %ext"
|
||||
/>
|
||||
</Box>
|
||||
</Section>
|
||||
</Paper>
|
||||
)
|
||||
|
||||
// Helper function for option updates
|
||||
function handleChange(field: string, value: any) {
|
||||
setOptions({ [field]: value })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { Box, Paper, Typography, LinearProgress, Alert, Chip } from '@mui/material'
|
||||
import { useTaskStore } from '../stores/taskStore'
|
||||
|
||||
export const ProgressDisplay: React.FC = () => {
|
||||
const { status, progress, message, error, tracks, logs } = useTaskStore()
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'info'
|
||||
case 'processing':
|
||||
return 'warning'
|
||||
case 'done':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'error'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = () => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Waiting'
|
||||
case 'processing':
|
||||
return 'Processing'
|
||||
case 'done':
|
||||
return 'Complete'
|
||||
case 'error':
|
||||
return 'Error'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">📊 Progress</Typography>
|
||||
<Chip label={getStatusLabel()} color={getStatusColor()} size="small" />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
color={status === 'error' ? 'error' : status === 'done' ? 'success' : 'primary'}
|
||||
sx={{ height: 10, borderRadius: 5 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
{progress}% – {message}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{tracks.length > 0 && status === 'done' && (
|
||||
<Alert severity="success" sx={{ mb: 2 }}>
|
||||
✅ {tracks.length} track(s) extracted successfully!
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box
|
||||
ref={logContainerRef}
|
||||
sx={{
|
||||
maxHeight: 200,
|
||||
overflowY: 'auto',
|
||||
bgcolor: 'background.default',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Waiting for progress updates...
|
||||
</Typography>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{log}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useTracklistStore } from '../stores/tracklistStore'
|
||||
import { useOptionsStore } from '../stores/optionsStore'
|
||||
import { parseAndValidateTracklist } from '../utils/validators'
|
||||
|
||||
export const TracklistEditor: React.FC = () => {
|
||||
const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
|
||||
const { options } = useOptionsStore()
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const validate = (text: string) => {
|
||||
const result = parseAndValidateTracklist(text, options.tracklist_format)
|
||||
setEntries(result.entries)
|
||||
setErrors(result.errors)
|
||||
setIsValid(result.isValid)
|
||||
}
|
||||
|
||||
const handleTextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = event.target.value
|
||||
setRawText(text)
|
||||
validate(text)
|
||||
}
|
||||
|
||||
const onDrop = useCallback(
|
||||
(acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length === 0) return
|
||||
|
||||
const file = acceptedFiles[0]
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const text = event.target?.result as string
|
||||
setRawText(text)
|
||||
validate(text)
|
||||
setIsDragging(false)
|
||||
}
|
||||
reader.readAsText(file)
|
||||
},
|
||||
[setRawText]
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'text/plain': ['.txt'],
|
||||
},
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
// Re-validate when tracklist format changes
|
||||
useEffect(() => {
|
||||
if (rawText) {
|
||||
validate(rawText)
|
||||
}
|
||||
}, [options.tracklist_format])
|
||||
|
||||
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
|
||||
|
||||
return (
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
p: 3,
|
||||
border: isDragActive ? '2px dashed' : '1px solid',
|
||||
borderColor: isDragActive ? 'primary.main' : 'divider',
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||
Tracklist
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
|
||||
{lineCount} track(s)
|
||||
{isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
|
||||
Format: {options.tracklist_format}
|
||||
</Typography>
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
{/* Line numbers column */}
|
||||
<Box
|
||||
sx={{
|
||||
minWidth: 40,
|
||||
maxWidth: 40,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.7,
|
||||
color: 'text.secondary',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{rawText.split('\n').map((_, i) => (
|
||||
<div key={i}>{i + 1}</div>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Editor text area */}
|
||||
<TextField
|
||||
multiline
|
||||
fullWidth
|
||||
minRows={10}
|
||||
maxRows={20}
|
||||
value={rawText}
|
||||
onChange={handleTextChange}
|
||||
placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '14px',
|
||||
lineHeight: 1.7,
|
||||
},
|
||||
}}
|
||||
error={!isValid && errors.length > 0}
|
||||
helperText={
|
||||
!isValid && errors.length > 0
|
||||
? errors.map((e) => `Line ${e.line}: ${e.message}`).join('; ')
|
||||
: 'Drop a .txt file here or paste your tracklist'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{isDragActive && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
Drop your tracklist file (.txt) here
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
|
||||
import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
|
||||
import { useUploadStore } from '../stores/uploadStore'
|
||||
import { uploadFile, getTaskInfo } from '../api/client'
|
||||
import { useTaskStore } from '../stores/taskStore'
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
|
||||
|
||||
export const UploadZone: React.FC = () => {
|
||||
const {
|
||||
file,
|
||||
fileName,
|
||||
fileSize,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
error,
|
||||
setFile,
|
||||
setFileName,
|
||||
setFileSize,
|
||||
setIsUploading,
|
||||
setUploadProgress,
|
||||
setError,
|
||||
setTaskId,
|
||||
setHasVideo,
|
||||
setHasAudio,
|
||||
setHasSubtitle,
|
||||
setAudioCodec,
|
||||
} = useUploadStore()
|
||||
|
||||
const { setTaskId: setTaskIdStore } = useTaskStore()
|
||||
|
||||
const onDrop = useCallback(
|
||||
async (acceptedFiles: File[]) => {
|
||||
if (acceptedFiles.length === 0) return
|
||||
|
||||
const selectedFile = acceptedFiles[0]
|
||||
const extension = '.' + selectedFile.name.split('.').pop()?.toLowerCase()
|
||||
|
||||
if (!ALLOWED_EXTENSIONS.includes(extension)) {
|
||||
setError(`Unsupported file format. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
setFile(selectedFile)
|
||||
setFileName(selectedFile.name)
|
||||
setFileSize(selectedFile.size)
|
||||
setError(null)
|
||||
setIsUploading(true)
|
||||
setUploadProgress(0)
|
||||
|
||||
try {
|
||||
const response = await uploadFile(selectedFile)
|
||||
const taskId = response.task_id
|
||||
setTaskId(taskId)
|
||||
setTaskIdStore(taskId)
|
||||
setUploadProgress(100)
|
||||
setIsUploading(false)
|
||||
|
||||
// Fetch stream info
|
||||
try {
|
||||
const info = await getTaskInfo(taskId)
|
||||
setHasVideo(info.has_video)
|
||||
setHasAudio(info.has_audio)
|
||||
setHasSubtitle(info.has_subtitle)
|
||||
setAudioCodec(info.audio_codec)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch stream info:', err)
|
||||
// Don't block the upload flow if this fails; we'll just assume no video
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Upload failed')
|
||||
setIsUploading(false)
|
||||
setUploadProgress(0)
|
||||
setFile(null)
|
||||
setFileName('')
|
||||
setFileSize(0)
|
||||
}
|
||||
},
|
||||
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
|
||||
)
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'audio/*': ALLOWED_EXTENSIONS,
|
||||
},
|
||||
multiple: false,
|
||||
disabled: isUploading,
|
||||
})
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Paper
|
||||
{...getRootProps()}
|
||||
sx={{
|
||||
p: 4,
|
||||
border: '2px dashed',
|
||||
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
|
||||
cursor: isUploading ? 'default' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{file ? (
|
||||
<Box>
|
||||
<InsertDriveFile sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
|
||||
<Typography variant="h6">{fileName}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{formatFileSize(fileSize)}
|
||||
</Typography>
|
||||
{isUploading && (
|
||||
<Box sx={{ mt: 2, width: '100%' }}>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{uploadProgress}% uploaded
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isUploading && (
|
||||
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
|
||||
✅ Uploaded successfully
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">
|
||||
{isDragActive ? 'Drop your audio file here' : 'Drag & drop your audio file here'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
or click to browse
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
|
||||
Supported formats: {ALLOWED_EXTENSIONS.join(', ')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTaskStore } from '../stores/taskStore'
|
||||
import { useUIStore } from '../stores/uiStore'
|
||||
import { getStatus } from '../api/client'
|
||||
|
||||
export const useWebSocket = (taskId: string | null) => {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const reconnectAttempts = useRef(0)
|
||||
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const {
|
||||
setStatus,
|
||||
setProgress,
|
||||
setMessage,
|
||||
setError,
|
||||
setTracks,
|
||||
addLog,
|
||||
setIsProcessing,
|
||||
status,
|
||||
} = useTaskStore()
|
||||
const { setWsConnected } = useUIStore()
|
||||
|
||||
const pollStatus = async () => {
|
||||
if (!taskId) return
|
||||
|
||||
try {
|
||||
const response = await getStatus(taskId)
|
||||
console.log('[Polling] Status response:', response)
|
||||
|
||||
// Update all state fields together
|
||||
setStatus(response.status)
|
||||
setProgress(response.progress)
|
||||
setMessage(response.message)
|
||||
|
||||
// Explicitly set tracks if present
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
console.log('[Polling] Setting tracks:', response.tracks)
|
||||
setTracks(response.tracks)
|
||||
}
|
||||
|
||||
if (response.status === 'done') {
|
||||
console.log('[Polling] Split complete, tracks set:', response.tracks)
|
||||
setIsProcessing(false)
|
||||
// Ensure tracks are set one more time (safety)
|
||||
if (response.tracks && response.tracks.length > 0) {
|
||||
setTracks(response.tracks)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (response.status === 'error') {
|
||||
setError(response.error || 'Split failed')
|
||||
setIsProcessing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Still processing – poll again
|
||||
if (pollingRef.current) {
|
||||
clearTimeout(pollingRef.current)
|
||||
}
|
||||
pollingRef.current = setTimeout(pollStatus, 2000)
|
||||
} catch (error) {
|
||||
console.error('[Polling] Error:', error)
|
||||
if (pollingRef.current) {
|
||||
clearTimeout(pollingRef.current)
|
||||
}
|
||||
pollingRef.current = setTimeout(pollStatus, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
setWsConnected(false)
|
||||
// Clear polling
|
||||
if (pollingRef.current) {
|
||||
clearTimeout(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
const wsUrl = `/ws/${taskId}`
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log(`WebSocket connected for task ${taskId}`)
|
||||
setWsConnected(true)
|
||||
reconnectAttempts.current = 0
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
// Start polling when connection is established
|
||||
// This ensures we get the final status even if WebSocket fails
|
||||
setTimeout(() => {
|
||||
pollStatus()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
console.log('[WebSocket] Message:', data)
|
||||
|
||||
if (data.type === 'status') {
|
||||
const statusData = data.data
|
||||
setStatus(statusData.status)
|
||||
setProgress(statusData.progress)
|
||||
setMessage(statusData.message)
|
||||
if (statusData.error) {
|
||||
setError(statusData.error)
|
||||
}
|
||||
if (statusData.tracks) {
|
||||
setTracks(statusData.tracks)
|
||||
}
|
||||
if (statusData.status === 'done' || statusData.status === 'error') {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
} else if (data.type === 'progress') {
|
||||
const progressData = data.data
|
||||
setStatus(progressData.status)
|
||||
setProgress(progressData.progress)
|
||||
setMessage(progressData.message)
|
||||
if (progressData.tracks) {
|
||||
setTracks(progressData.tracks)
|
||||
}
|
||||
if (progressData.status === 'done') {
|
||||
addLog('✅ Split complete!')
|
||||
setIsProcessing(false)
|
||||
} else if (progressData.status === 'error') {
|
||||
setError(progressData.message)
|
||||
addLog(`❌ Error: ${progressData.message}`)
|
||||
setIsProcessing(false)
|
||||
} else {
|
||||
addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WebSocket] Failed to parse message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log(`WebSocket disconnected for task ${taskId}`)
|
||||
setWsConnected(false)
|
||||
|
||||
// If task is not done and we have a taskId, start polling
|
||||
// We check the status store to see if it's already done
|
||||
if (taskId && status !== 'done' && status !== 'error') {
|
||||
console.log('[WebSocket] Disconnected while processing, starting polling...')
|
||||
setTimeout(pollStatus, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('[WebSocket] Error:', error)
|
||||
// onclose will handle reconnection
|
||||
}
|
||||
|
||||
wsRef.current = ws
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
}
|
||||
if (pollingRef.current) {
|
||||
clearTimeout(pollingRef.current)
|
||||
pollingRef.current = null
|
||||
}
|
||||
setWsConnected(false)
|
||||
}
|
||||
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing, status])
|
||||
|
||||
return wsRef.current
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,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 } }),
|
||||
}))
|
||||
@@ -0,0 +1,54 @@
|
||||
import { create } from 'zustand'
|
||||
import { TrackInfo } from '../types'
|
||||
|
||||
interface TaskState {
|
||||
taskId: string | null
|
||||
status: 'pending' | 'processing' | 'done' | 'error' | null
|
||||
progress: number
|
||||
message: string
|
||||
error: string | null
|
||||
tracks: TrackInfo[]
|
||||
logs: string[]
|
||||
isProcessing: boolean
|
||||
|
||||
setTaskId: (taskId: string | null) => void
|
||||
setStatus: (status: 'pending' | 'processing' | 'done' | 'error' | null) => void
|
||||
setProgress: (progress: number) => void
|
||||
setMessage: (message: string) => void
|
||||
setError: (error: string | null) => void
|
||||
setTracks: (tracks: TrackInfo[]) => void
|
||||
addLog: (log: string) => void
|
||||
setIsProcessing: (isProcessing: boolean) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useTaskStore = create<TaskState>((set) => ({
|
||||
taskId: null,
|
||||
status: null,
|
||||
progress: 0,
|
||||
message: '',
|
||||
error: null,
|
||||
tracks: [],
|
||||
logs: [],
|
||||
isProcessing: false,
|
||||
|
||||
setTaskId: (taskId) => set({ taskId }),
|
||||
setStatus: (status) => set({ status }),
|
||||
setProgress: (progress) => set({ progress }),
|
||||
setMessage: (message) => set({ message }),
|
||||
setError: (error) => set({ error }),
|
||||
setTracks: (tracks) => set({ tracks }),
|
||||
addLog: (log) => set((state) => ({ logs: [...state.logs, log] })),
|
||||
setIsProcessing: (isProcessing) => set({ isProcessing }),
|
||||
reset: () =>
|
||||
set({
|
||||
taskId: null,
|
||||
status: null,
|
||||
progress: 0,
|
||||
message: '',
|
||||
error: null,
|
||||
tracks: [],
|
||||
logs: [],
|
||||
isProcessing: false,
|
||||
}),
|
||||
}))
|
||||
@@ -0,0 +1,39 @@
|
||||
import { create } from 'zustand'
|
||||
import { TracklistEntry } from '../types'
|
||||
|
||||
interface TracklistState {
|
||||
rawText: string
|
||||
entries: TracklistEntry[]
|
||||
errors: { line: number; message: string }[]
|
||||
isValid: boolean
|
||||
isDragging: boolean
|
||||
|
||||
setRawText: (text: string) => void
|
||||
setEntries: (entries: TracklistEntry[]) => void
|
||||
setErrors: (errors: { line: number; message: string }[]) => void
|
||||
setIsValid: (isValid: boolean) => void
|
||||
setIsDragging: (isDragging: boolean) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useTracklistStore = create<TracklistState>((set) => ({
|
||||
rawText: '',
|
||||
entries: [],
|
||||
errors: [],
|
||||
isValid: false,
|
||||
isDragging: false,
|
||||
|
||||
setRawText: (rawText) => set({ rawText }),
|
||||
setEntries: (entries) => set({ entries }),
|
||||
setErrors: (errors) => set({ errors }),
|
||||
setIsValid: (isValid) => set({ isValid }),
|
||||
setIsDragging: (isDragging) => set({ isDragging }),
|
||||
reset: () =>
|
||||
set({
|
||||
rawText: '',
|
||||
entries: [],
|
||||
errors: [],
|
||||
isValid: false,
|
||||
isDragging: false,
|
||||
}),
|
||||
}))
|
||||
@@ -0,0 +1,31 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface UIState {
|
||||
theme: 'light' | 'dark'
|
||||
isSidebarOpen: boolean
|
||||
wsConnected: boolean
|
||||
|
||||
toggleTheme: () => void
|
||||
setTheme: (theme: 'light' | 'dark') => void
|
||||
toggleSidebar: () => void
|
||||
setSidebarOpen: (isOpen: boolean) => void
|
||||
setWsConnected: (connected: boolean) => void
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
theme: 'light',
|
||||
isSidebarOpen: false,
|
||||
wsConnected: false,
|
||||
|
||||
toggleTheme: () =>
|
||||
set((state) => ({
|
||||
theme: state.theme === 'light' ? 'dark' : 'light',
|
||||
})),
|
||||
setTheme: (theme) => set({ theme }),
|
||||
toggleSidebar: () =>
|
||||
set((state) => ({
|
||||
isSidebarOpen: !state.isSidebarOpen,
|
||||
})),
|
||||
setSidebarOpen: (isSidebarOpen) => set({ isSidebarOpen }),
|
||||
setWsConnected: (wsConnected) => set({ wsConnected }),
|
||||
}))
|
||||
@@ -0,0 +1,69 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface UploadState {
|
||||
file: File | null
|
||||
taskId: string | null
|
||||
fileName: string
|
||||
fileSize: number
|
||||
isUploading: boolean
|
||||
uploadProgress: number
|
||||
error: string | null
|
||||
// New fields for stream info
|
||||
hasVideo: boolean
|
||||
hasAudio: boolean
|
||||
hasSubtitle: boolean
|
||||
audioCodec: string | null
|
||||
|
||||
setFile: (file: File | null) => void
|
||||
setTaskId: (taskId: string | null) => void
|
||||
setFileName: (name: string) => void
|
||||
setFileSize: (size: number) => void
|
||||
setIsUploading: (isUploading: boolean) => void
|
||||
setUploadProgress: (progress: number) => void
|
||||
setError: (error: string | null) => void
|
||||
setHasVideo: (hasVideo: boolean) => void
|
||||
setHasAudio: (hasAudio: boolean) => void
|
||||
setHasSubtitle: (hasSubtitle: boolean) => void
|
||||
setAudioCodec: (audioCodec: string | null) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useUploadStore = create<UploadState>((set) => ({
|
||||
file: null,
|
||||
taskId: null,
|
||||
fileName: '',
|
||||
fileSize: 0,
|
||||
isUploading: false,
|
||||
uploadProgress: 0,
|
||||
error: null,
|
||||
hasVideo: false,
|
||||
hasAudio: false,
|
||||
hasSubtitle: false,
|
||||
audioCodec: null,
|
||||
|
||||
setFile: (file) => set({ file }),
|
||||
setTaskId: (taskId) => set({ taskId }),
|
||||
setFileName: (fileName) => set({ fileName }),
|
||||
setFileSize: (fileSize) => set({ fileSize }),
|
||||
setIsUploading: (isUploading) => set({ isUploading }),
|
||||
setUploadProgress: (uploadProgress) => set({ uploadProgress }),
|
||||
setError: (error) => set({ error }),
|
||||
setHasVideo: (hasVideo) => set({ hasVideo }),
|
||||
setHasAudio: (hasAudio) => set({ hasAudio }),
|
||||
setHasSubtitle: (hasSubtitle) => set({ hasSubtitle }),
|
||||
setAudioCodec: (audioCodec) => set({ audioCodec }),
|
||||
reset: () =>
|
||||
set({
|
||||
file: null,
|
||||
taskId: null,
|
||||
fileName: '',
|
||||
fileSize: 0,
|
||||
isUploading: false,
|
||||
uploadProgress: 0,
|
||||
error: null,
|
||||
hasVideo: false,
|
||||
hasAudio: false,
|
||||
hasSubtitle: false,
|
||||
audioCodec: null,
|
||||
}),
|
||||
}))
|
||||
@@ -0,0 +1,11 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface ValidationState {
|
||||
formatError: string | null
|
||||
setFormatError: (error: string | null) => void
|
||||
}
|
||||
|
||||
export const useValidationStore = create<ValidationState>((set) => ({
|
||||
formatError: null,
|
||||
setFormatError: (error) => set({ formatError: error }),
|
||||
}))
|
||||
@@ -0,0 +1,53 @@
|
||||
export interface TracklistEntry {
|
||||
ts: string
|
||||
tn?: string
|
||||
an?: string
|
||||
al?: string
|
||||
date?: string
|
||||
ext?: string
|
||||
}
|
||||
|
||||
export interface TrackInfo {
|
||||
filename: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface TaskStatus {
|
||||
task_id: string
|
||||
status: 'pending' | 'processing' | 'done' | 'error'
|
||||
progress: number
|
||||
message: string
|
||||
error: string | null
|
||||
tracks: TrackInfo[]
|
||||
}
|
||||
|
||||
export interface SplitOptions {
|
||||
format: string
|
||||
transcode_to?: string
|
||||
drop_video: boolean
|
||||
drop_subs: boolean
|
||||
number_tracks: boolean
|
||||
replace_bad_chars: boolean
|
||||
replacement_char: string
|
||||
bad_chars: string
|
||||
skip_existing: boolean
|
||||
output_template: string
|
||||
album: string
|
||||
comment: string
|
||||
no_comment: boolean
|
||||
comment_stream: number | null
|
||||
merge_comments: boolean
|
||||
comment_separator: string
|
||||
tracklist_format: string
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
task_id: string
|
||||
filename: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface SplitResponse {
|
||||
task_id: string
|
||||
status: string
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// web/frontend/src/utils/parser.ts
|
||||
|
||||
import { TracklistEntry } from '../types'
|
||||
|
||||
type Token = { type: 'literal'; value: string } | { type: 'placeholder'; value: string }
|
||||
|
||||
export function parseFormat(formatStr: string): Token[] {
|
||||
const validPlaceholders = new Set(['ts', 'tn', 'an', 'al', 'date', 'ext'])
|
||||
const tokens: Token[] = []
|
||||
let i = 0
|
||||
while (i < formatStr.length) {
|
||||
const ch = formatStr[i]
|
||||
if (ch === '%') {
|
||||
if (i + 1 < formatStr.length && formatStr[i + 1] === '%') {
|
||||
tokens.push({ type: 'literal', value: '%' })
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// match %letters
|
||||
const match = formatStr.substring(i).match(/^%([a-zA-Z]+)/)
|
||||
if (!match) {
|
||||
throw new Error(`Invalid placeholder at position ${i}: '${formatStr.substring(i)}'`)
|
||||
}
|
||||
const placeholder = match[1]
|
||||
if (!validPlaceholders.has(placeholder)) {
|
||||
throw new Error(`Unknown placeholder '%${placeholder}'. Allowed: ${Array.from(validPlaceholders).join(', ')}`)
|
||||
}
|
||||
tokens.push({ type: 'placeholder', value: placeholder })
|
||||
i += match[0].length
|
||||
} else {
|
||||
let j = i
|
||||
while (j < formatStr.length && formatStr[j] !== '%') {
|
||||
j++
|
||||
}
|
||||
tokens.push({ type: 'literal', value: formatStr.substring(i, j) })
|
||||
i = j
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
export function parseLine(line: string, tokens: Token[]): Record<string, string | null> {
|
||||
line = line.trim()
|
||||
if (!line) {
|
||||
throw new Error('Empty line')
|
||||
}
|
||||
|
||||
const result: Record<string, string | null> = {}
|
||||
let pos = 0
|
||||
|
||||
for (let idx = 0; idx < tokens.length; idx++) {
|
||||
const token = tokens[idx]
|
||||
if (token.type === 'literal') {
|
||||
const literal = token.value
|
||||
if (!line.startsWith(literal, pos)) {
|
||||
throw new Error(`Expected literal '${literal}' at position ${pos}, got '${line.substring(pos)}'`)
|
||||
}
|
||||
pos += literal.length
|
||||
} else {
|
||||
// placeholder
|
||||
const placeholder = token.value
|
||||
// If this is the last token, capture the rest
|
||||
if (idx === tokens.length - 1) {
|
||||
const value = line.substring(pos).trim()
|
||||
result[placeholder] = value || null
|
||||
pos = line.length
|
||||
} else {
|
||||
// Find the next literal to use as delimiter
|
||||
let nextLiteral: string | null = null
|
||||
for (let j = idx + 1; j < tokens.length; j++) {
|
||||
if (tokens[j].type === 'literal') {
|
||||
nextLiteral = tokens[j].value
|
||||
break
|
||||
}
|
||||
}
|
||||
if (nextLiteral === null) {
|
||||
const value = line.substring(pos).trim()
|
||||
result[placeholder] = value || null
|
||||
pos = line.length
|
||||
} else {
|
||||
const nextPos = line.indexOf(nextLiteral, pos)
|
||||
if (nextPos === -1) {
|
||||
throw new Error(`Could not find literal '${nextLiteral}' after placeholder '${placeholder}'`)
|
||||
}
|
||||
const value = line.substring(pos, nextPos).trim()
|
||||
result[placeholder] = value || null
|
||||
pos = nextPos
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function parseTracklistWithFormat(text: string, format: string): TracklistEntry[] {
|
||||
const tokens = parseFormat(format)
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '')
|
||||
const entries: TracklistEntry[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = parseLine(line, tokens)
|
||||
const entry: TracklistEntry = {
|
||||
ts: parsed.ts || '',
|
||||
tn: parsed.tn || '',
|
||||
an: parsed.an || '',
|
||||
al: parsed.al || '',
|
||||
date: parsed.date || '',
|
||||
ext: parsed.ext || '',
|
||||
}
|
||||
entries.push(entry)
|
||||
} catch (error) {
|
||||
// We'll handle errors in the validator; just skip or mark as invalid
|
||||
// For now, we'll push an empty entry with an error flag
|
||||
entries.push({ ts: '', tn: line, an: '' })
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// web/frontend/src/utils/validators.ts
|
||||
import { TracklistEntry } from '../types'
|
||||
import { parseTracklistWithFormat } from './parser'
|
||||
|
||||
export const validateTracklist = (
|
||||
entries: TracklistEntry[]
|
||||
): { isValid: boolean; errors: { line: number; message: string }[] } => {
|
||||
const errors: { line: number; message: string }[] = []
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const lineNum = index + 1
|
||||
if (!entry.ts || !entry.ts.trim()) {
|
||||
errors.push({ line: lineNum, message: 'Missing timestamp (%ts)' })
|
||||
} else {
|
||||
// Validate timestamp format
|
||||
const ts = entry.ts.trim()
|
||||
if (!/^\d{1,2}:\d{2}(:\d{2})?$/.test(ts) && !/^\d{1,2}:\d{2}-\d{1,2}:\d{2}$/.test(ts)) {
|
||||
errors.push({ line: lineNum, message: 'Invalid timestamp format. Expected mm:ss or mm:ss-HH:MM:SS' })
|
||||
}
|
||||
}
|
||||
if (!entry.tn || !entry.tn.trim()) {
|
||||
errors.push({ line: lineNum, message: 'Missing track name (%tn)' })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
// New function that parses and validates using the format
|
||||
export function parseAndValidateTracklist(text: string, format: string): {
|
||||
entries: TracklistEntry[]
|
||||
errors: { line: number; message: string }[]
|
||||
isValid: boolean
|
||||
} {
|
||||
// We need to handle parsing errors gracefully.
|
||||
// parseTracklistWithFormat will throw on some errors, but we can catch and mark as invalid.
|
||||
try {
|
||||
const entries = parseTracklistWithFormat(text, format)
|
||||
const validation = validateTracklist(entries)
|
||||
return {
|
||||
entries,
|
||||
errors: validation.errors,
|
||||
isValid: validation.isValid,
|
||||
}
|
||||
} catch (error) {
|
||||
// If parsing fails (e.g., invalid format), treat all lines as errors
|
||||
const lines = text.split('\n').filter(line => line.trim() !== '')
|
||||
const errors = lines.map((_, index) => ({
|
||||
line: index + 1,
|
||||
message: error instanceof Error ? error.message : 'Parse error',
|
||||
}))
|
||||
return {
|
||||
entries: [],
|
||||
errors,
|
||||
isValid: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// Backend URL for proxy (default to localhost for local dev)
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: BACKEND_URL,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/ws': {
|
||||
target: BACKEND_URL.replace(/^http/, 'ws'),
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
python-multipart>=0.0.6
|
||||
aiofiles>=23.2.0
|
||||
pydantic>=2.5.0
|
||||
pydantic-settings>=2.0.0
|
||||
python-dotenv>=1.0.0
|
||||
websockets>=12.0
|
||||
Reference in New Issue
Block a user