9 Commits

64 changed files with 7249 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
# Backend
DEBUG=0
MAX_UPLOAD_SIZE_MB=500
TEMP_DIR=/tmp/audio_splitter_web
CLEANUP_AFTER_SECONDS=3600
# Frontend (development)
VITE_BACKEND_URL=http://localhost:8000
+4 -1
View File
@@ -3,4 +3,7 @@ build/
dist/ dist/
*.egg-info/ *.egg-info/
*.pyc *.pyc
test_data/ test_data/
venv/
web/frontend/node_modules
TODO.md
+1 -1
View File
@@ -54,4 +54,4 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# Default command (shows help if no arguments provided) # Default command (shows help if no arguments provided)
CMD ["--help"] CMD ["--help"]
+29
View File
@@ -0,0 +1,29 @@
services:
backend:
build:
context: .
dockerfile: web/backend/Dockerfile
ports:
- "8000:8000"
volumes:
- /tmp/audio_splitter_web:/tmp/audio_splitter_web # Bind mount
environment:
- PYTHONUNBUFFERED=1
- DEBUG=1
restart: unless-stopped
frontend:
build:
context: ./web/frontend
dockerfile: Dockerfile
ports:
- "5173:80"
volumes:
- ./web/frontend:/app
- /app/node_modules
environment:
- BACKEND_URL=http://backend:8000
- VITE_BACKEND_URL=http://backend:8000
depends_on:
- backend
restart: unless-stopped
+18
View File
@@ -0,0 +1,18 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.venv
venv
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore
+4
View File
@@ -0,0 +1,4 @@
MAX_UPLOAD_SIZE_MB=500
TEMP_DIR=/tmp/audio_splitter_web
CLEANUP_AFTER_SECONDS=3600
ALLOW_ORIGINS=http://localhost:5173,http://localhost:3000
+45
View File
@@ -0,0 +1,45 @@
FROM python:3.13-slim
# Install FFmpeg, system dependencies, and gosu from APT
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ffmpeg \
ca-certificates \
gosu \
&& \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# Set working directory
WORKDIR /app
# Copy requirements and install Python dependencies
COPY web/backend/requirements-web.txt .
RUN pip install --no-cache-dir -r requirements-web.txt
# Copy backend code
COPY web/backend /app/backend
# Copy and install audio_splitter package
COPY audio_splitter /app/audio_splitter
COPY setup.py pyproject.toml README.md /app/
RUN pip install --no-cache-dir /app
# Create a non-root user
RUN addgroup --system --gid 1000 appgroup && \
adduser --system --uid 1000 --ingroup appgroup appuser && \
chown -R appuser:appgroup /app
# Copy entrypoint script
COPY web/backend/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# Set entrypoint
ENTRYPOINT ["/docker-entrypoint.sh"]
# Expose port
EXPOSE 8000
+8
View File
@@ -0,0 +1,8 @@
# From the web/ directory
cd web
# Install dependencies
pip install -r requirements-web.txt
# Run the server (note the module path: backend.main)
uvicorn backend.main:app --reload --port 8000
+1
View File
@@ -0,0 +1 @@
"""Audio Splitter Web Backend"""
+4
View File
@@ -0,0 +1,4 @@
"""API route handlers."""
from . import upload, split, status, download
# websocket is imported directly in main.py to avoid circular import
+90
View File
@@ -0,0 +1,90 @@
"""Download endpoint for split results."""
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from zipfile import ZipFile
import os
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.models.response import TaskStatus
router = APIRouter(prefix="/api", tags=["download"])
def _prepare_download(task_id: str):
"""Common logic to prepare and return the ZIP file."""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status["status"] != TaskStatus.DONE:
raise HTTPException(
status_code=400,
detail=f"Task {task_id} is not complete. Current status: {status['status']}"
)
output_dir = settings.temp_dir / task_id / "output"
if not output_dir.exists() or not any(output_dir.iterdir()):
raise HTTPException(status_code=404, detail="No output files found")
zip_path = settings.temp_dir / task_id / "splits.zip"
with ZipFile(zip_path, "w") as zipf:
for file_path in output_dir.iterdir():
if file_path.is_file():
zipf.write(file_path, arcname=file_path.name)
return zip_path
@router.get("/download/{task_id}")
async def download_results(task_id: str):
zip_path = _prepare_download(task_id)
return FileResponse(
path=zip_path,
media_type="application/zip",
filename=f"{task_id}_splits.zip",
headers={"Content-Disposition": f"attachment; filename={task_id}_splits.zip"}
)
@router.get("/download/{task_id}/splits.zip")
async def download_results_as_zip(task_id: str):
"""Alias endpoint that provides a .zip suffix for easier curl usage."""
zip_path = _prepare_download(task_id)
return FileResponse(
path=zip_path,
media_type="application/zip",
filename="splits.zip",
headers={"Content-Disposition": "attachment; filename=splits.zip"}
)
# NEW: Endpoint for downloading individual tracks
@router.get("/download/{task_id}/{filename}")
async def download_single_track(task_id: str, filename: str):
"""
Download a single track file from the output directory.
"""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status["status"] != TaskStatus.DONE:
raise HTTPException(
status_code=400,
detail=f"Task {task_id} is not complete. Current status: {status['status']}"
)
output_dir = settings.temp_dir / task_id / "output"
file_path = output_dir / filename
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File '{filename}' not found")
return FileResponse(
path=file_path,
filename=filename,
media_type="application/octet-stream",
headers={"Content-Disposition": f"attachment; filename={filename}"}
)
+25
View File
@@ -0,0 +1,25 @@
"""Endpoint to expose format information to the frontend."""
from fastapi import APIRouter
from backend.constants import FORMAT_INFO
router = APIRouter(prefix="/api", tags=["formats"])
@router.get("/formats")
async def get_formats():
"""
Return the list of supported container formats with their properties.
"""
return {
"formats": [
{
"name": name,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
}
+35
View File
@@ -0,0 +1,35 @@
"""Endpoint to retrieve stream information for an uploaded file."""
from fastapi import APIRouter, HTTPException
from backend.config import settings
from backend.services.file_manager import FileManager
from backend.services.task_manager import task_manager
from backend.ffmpeg import get_stream_info
router = APIRouter(prefix="/api", tags=["info"])
@router.get("/info/{task_id}")
async def get_task_info(task_id: str):
"""
Return stream information (has_audio, has_video, has_subtitle) for the uploaded file.
"""
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
raise HTTPException(status_code=404, detail="Input file not found")
try:
info = get_stream_info(str(input_path))
return {
"task_id": task_id,
"has_audio": info["has_audio"],
"has_video": info["has_video"],
"has_subtitle": info["has_subtitle"],
"audio_codec": info["audio_codec"],
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
+39
View File
@@ -0,0 +1,39 @@
"""Split task endpoint."""
from fastapi import APIRouter, HTTPException, BackgroundTasks
from backend.models.request import SplitRequest
from backend.models.response import SplitResponse, TaskStatus
from backend.services.splitter import run_split_task
from backend.services.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["split"])
@router.post("/split", response_model=SplitResponse)
async def start_split(request: SplitRequest, background_tasks: BackgroundTasks):
task_id = request.task_id
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE):
raise HTTPException(
status_code=409,
detail=f"Task {task_id} is already {status['status']}"
)
task_manager.update_task(
task_id,
status=TaskStatus.PROCESSING,
progress=0,
message="Preparing to split..."
)
background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options)
return SplitResponse(
task_id=task_id,
status=TaskStatus.PROCESSING
)
+25
View File
@@ -0,0 +1,25 @@
"""Status query endpoint."""
from fastapi import APIRouter, HTTPException
from backend.models.response import StatusResponse
from backend.services.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["status"])
@router.get("/status/{task_id}", response_model=StatusResponse)
async def get_status(task_id: str):
if not task_manager.has_task(task_id):
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
status = task_manager.get_status(task_id)
return StatusResponse(
task_id=task_id,
status=status["status"],
progress=status["progress"],
message=status["message"],
error=status.get("error"),
tracks=status.get("tracks", [])
)
+58
View File
@@ -0,0 +1,58 @@
"""File upload endpoint."""
import uuid
import shutil
from pathlib import Path
from fastapi import APIRouter, UploadFile, File, HTTPException
from backend.config import settings
from backend.models.response import UploadResponse
from backend.services.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["upload"])
ALLOWED_EXTENSIONS = {
".mp3", ".flac", ".wav", ".m4a", ".ogg", ".opus",
".aac", ".wma", ".aiff", ".alac", ".ac3"
}
@router.post("/upload", response_model=UploadResponse)
async def upload_file(file: UploadFile = File(...)):
extension = Path(file.filename).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"Unsupported file format. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}"
)
task_id = str(uuid.uuid4())[:8]
task_dir = settings.temp_dir / task_id
task_dir.mkdir(parents=True, exist_ok=True)
input_path = task_dir / f"input{extension}"
try:
with open(input_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
shutil.rmtree(task_dir, ignore_errors=True)
raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}")
file_size = input_path.stat().st_size
max_size_bytes = settings.max_upload_size_mb * 1024 * 1024
if file_size > max_size_bytes:
shutil.rmtree(task_dir, ignore_errors=True)
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size: {settings.max_upload_size_mb} MB"
)
# Create task entry in the task manager
task_manager.create_task(task_id, file.filename, file_size)
return UploadResponse(
task_id=task_id,
filename=file.filename,
size=file_size
)
+46
View File
@@ -0,0 +1,46 @@
"""WebSocket endpoint for realtime progress updates."""
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from backend.services.task_manager import task_manager
from backend.services.progress_publisher import register_connection, unregister_connection
router = APIRouter(tags=["websocket"])
@router.websocket("/ws/{task_id}")
async def websocket_endpoint(websocket: WebSocket, task_id: str):
await websocket.accept()
register_connection(task_id, websocket)
try:
# Send initial status
status = task_manager.get_status(task_id)
await websocket.send_json({
"type": "status",
"data": status
})
while True:
data = await websocket.receive_text()
try:
message = json.loads(data)
if message.get("type") == "ping":
await websocket.send_json({"type": "pong"})
elif message.get("type") == "get_status":
status = task_manager.get_status(task_id)
await websocket.send_json({
"type": "status",
"data": status
})
except json.JSONDecodeError:
pass
except WebSocketDisconnect:
unregister_connection(task_id, websocket)
except Exception as e:
unregister_connection(task_id, websocket)
print(f"WebSocket error: {e}")
+33
View File
@@ -0,0 +1,33 @@
"""Configuration settings for the web backend."""
import os
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Server
host: str = "0.0.0.0"
port: int = 8000
debug: bool = False
# File handling
max_upload_size_mb: int = 500
temp_dir: Path = Path("/tmp/audio_splitter_web")
cleanup_after_seconds: int = 3600 # 1 hour
# CORS
allow_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
# Ensure temp directory exists
settings.temp_dir.mkdir(parents=True, exist_ok=True)
+12
View File
@@ -0,0 +1,12 @@
FORMAT_INFO = {
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
}
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -e
# Detect if we are running as root (default)
if [ "$(id -u)" = "0" ]; then
# Ensure the temp directory exists and set ownership
if [ -d "/tmp/audio_splitter_web" ]; then
echo "Setting ownership of /tmp/audio_splitter_web to appuser:appgroup"
chown -R appuser:appgroup /tmp/audio_splitter_web
else
echo "Creating /tmp/audio_splitter_web and setting ownership"
mkdir -p /tmp/audio_splitter_web
chown -R appuser:appgroup /tmp/audio_splitter_web
fi
# Drop privileges and run uvicorn using gosu
exec gosu appuser uvicorn backend.main:app --host 0.0.0.0 --port 8000
else
# If not root, just run directly
exec uvicorn backend.main:app --host 0.0.0.0 --port 8000
fi
+79
View File
@@ -0,0 +1,79 @@
"""FFmpeg/FFprobe interaction utilities for the web backend."""
import subprocess
import json
def get_stream_info(input_file: str):
"""
Retrieve stream information (audio, video, subtitle presence) from a media file.
Returns a dict with keys: has_audio, has_video, has_subtitle, audio_codec.
"""
# Get audio codec (if any)
audio_codec = None
try:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'a:0',
'-show_entries', 'stream=codec_name',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
codec = result.stdout.strip().lower()
if codec:
audio_codec = codec
except Exception:
pass
# Check for video stream
has_video = False
try:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 'v',
'-show_entries', 'stream=codec_type',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
has_video = bool(result.stdout.strip())
except Exception:
pass
# Check for subtitle stream
has_subtitle = False
try:
cmd = [
'ffprobe', '-v', 'error',
'-select_streams', 's',
'-show_entries', 'stream=codec_type',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
has_subtitle = bool(result.stdout.strip())
except Exception:
pass
return {
'has_audio': audio_codec is not None,
'has_video': has_video,
'has_subtitle': has_subtitle,
'audio_codec': audio_codec,
}
def get_audio_duration(input_file: str) -> float:
"""Get the duration of the audio file in seconds."""
cmd = [
'ffprobe', '-v', 'error',
'-show_entries', 'format=duration',
'-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
try:
return float(result.stdout.strip())
except ValueError:
return 0.0
+50
View File
@@ -0,0 +1,50 @@
"""FastAPI application entry point."""
import asyncio
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from backend.config import settings
from backend.api import upload, split, status, download, websocket, formats, info
from backend.services import progress_publisher
app = FastAPI(
title="Audio Splitter Web API",
description="Web interface for splitting audio files using a tracklist",
version="0.1.0",
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.allow_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.on_event("startup")
async def startup_event():
"""Store the main event loop for use in other threads."""
progress_publisher.MAIN_LOOP = asyncio.get_running_loop()
# Include routers
app.include_router(upload.router)
app.include_router(split.router)
app.include_router(status.router)
app.include_router(download.router)
app.include_router(websocket.router)
app.include_router(formats.router) # new
app.include_router(info.router) # new
@app.get("/")
async def root():
return {"status": "ok", "service": "Audio Splitter Web API"}
@app.get("/health")
async def health():
return {"status": "healthy"}
+1
View File
@@ -0,0 +1 @@
"""Pydantic models for request/response validation."""
+58
View File
@@ -0,0 +1,58 @@
"""Request models for API endpoints."""
from typing import Optional, List
from pydantic import BaseModel, Field, validator
class TracklistEntry(BaseModel):
"""A single track entry from the tracklist."""
ts: str = Field(..., description="Timestamp (e.g., '00:00' or '00:00-01:30')")
tn: Optional[str] = Field("", description="Track name")
an: Optional[str] = Field("", description="Author/artist")
al: Optional[str] = Field("", description="Album")
date: Optional[str] = Field("", description="Date/year")
ext: Optional[str] = Field("", description="File extension")
@validator("ts")
def validate_timestamp(cls, v: str) -> str:
"""Basic timestamp validation (format and range)."""
v = v.strip()
if not v:
raise ValueError("Timestamp cannot be empty")
# Check for range format (start-end)
if "-" in v:
parts = v.split("-", 1)
start = parts[0].strip()
end = parts[1].strip()
if not start or not end:
raise ValueError("Invalid range format. Expected 'start-end'")
# Validate each part with the same logic
for ts in [start, end]:
cls._validate_single_timestamp(ts)
else:
cls._validate_single_timestamp(v)
return v
@staticmethod
def _validate_single_timestamp(ts: str) -> None:
"""Validate a single timestamp (mm:ss or HH:MM:SS)."""
parts = ts.split(":")
if len(parts) not in (2, 3):
raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS")
try:
for p in parts:
int(p)
except ValueError:
raise ValueError(f"Invalid timestamp: {ts}. Must contain only numbers.")
class SplitRequest(BaseModel):
"""Request model for the split endpoint."""
task_id: str = Field(..., description="Task ID from upload")
tracklist: List[TracklistEntry] = Field(..., description="List of tracks")
options: dict = Field(default_factory=dict, description="All CLI options")
+38
View File
@@ -0,0 +1,38 @@
"""Response models for API endpoints."""
from typing import Optional, List
from enum import Enum
from pydantic import BaseModel, Field
class TaskStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
DONE = "done"
ERROR = "error"
class TrackInfo(BaseModel):
filename: str
size: int # bytes
class UploadResponse(BaseModel):
task_id: str
filename: str
size: int
class SplitResponse(BaseModel):
task_id: str
status: TaskStatus
class StatusResponse(BaseModel):
task_id: str
status: TaskStatus
progress: int = Field(0, ge=0, le=100)
message: str = ""
error: Optional[str] = None
tracks: List[TrackInfo] = []
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
python-multipart>=0.0.6
aiofiles>=23.2.0
pydantic>=2.5.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
websockets>=12.0
+1
View File
@@ -0,0 +1 @@
"""Business logic services."""
+47
View File
@@ -0,0 +1,47 @@
"""Temporary file management for web operations."""
import shutil
from pathlib import Path
from backend.config import settings
class FileManager:
@staticmethod
def get_task_dir(task_id: str) -> Path:
return settings.temp_dir / task_id
@staticmethod
def get_input_path(task_id: str) -> Path:
task_dir = FileManager.get_task_dir(task_id)
for f in task_dir.iterdir():
if f.name.startswith("input."):
return f
return None
@staticmethod
def get_output_dir(task_id: str) -> Path:
return FileManager.get_task_dir(task_id) / "output"
@staticmethod
def ensure_output_dir(task_id: str) -> Path:
output_dir = FileManager.get_output_dir(task_id)
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir
@staticmethod
def cleanup_task(task_id: str) -> None:
task_dir = FileManager.get_task_dir(task_id)
if task_dir.exists():
shutil.rmtree(task_dir, ignore_errors=True)
@staticmethod
def get_output_files(task_id: str) -> list:
output_dir = FileManager.get_output_dir(task_id)
if not output_dir.exists():
return []
files = []
for f in output_dir.iterdir():
if f.is_file():
files.append({"filename": f.name, "size": f.stat().st_size})
return files
@@ -0,0 +1,66 @@
"""WebSocket progress publisher decouples task manager from WebSocket."""
import asyncio
from typing import Dict, Set
from fastapi import WebSocket
# This will be set by main.py during startup
MAIN_LOOP = None
active_connections: Dict[str, Set[WebSocket]] = {}
def publish_progress(task_id: str, progress: int, message: str, status: str = "processing"):
"""
Publish progress update to all connected WebSocket clients for a task.
"""
if task_id not in active_connections:
return
data = {
"type": "progress",
"data": {
"task_id": task_id,
"status": status,
"progress": progress,
"message": message
}
}
to_remove = set()
# Get the loop to use: either the stored one or try to get the current loop
loop = MAIN_LOOP
if loop is None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# No running loop, fallback to default event loop
loop = asyncio.get_event_loop()
for websocket in active_connections.get(task_id, set()):
try:
asyncio.run_coroutine_threadsafe(websocket.send_json(data), loop)
except Exception:
to_remove.add(websocket)
# Clean up disconnected clients
for websocket in to_remove:
active_connections[task_id].discard(websocket)
if task_id in active_connections and not active_connections[task_id]:
del active_connections[task_id]
def register_connection(task_id: str, websocket: WebSocket):
"""Register a WebSocket connection for a task."""
if task_id not in active_connections:
active_connections[task_id] = set()
active_connections[task_id].add(websocket)
def unregister_connection(task_id: str, websocket: WebSocket):
"""Unregister a WebSocket connection for a task."""
if task_id in active_connections:
active_connections[task_id].discard(websocket)
if not active_connections[task_id]:
del active_connections[task_id]
+103
View File
@@ -0,0 +1,103 @@
"""Splitter service that calls the core audio_splitter logic."""
import os
import sys
import shutil
from pathlib import Path
from typing import List, Dict, Any
import time
from backend.config import settings
from backend.services.task_manager import task_manager
from backend.services.file_manager import FileManager
from backend.models.request import TracklistEntry
from backend.models.response import TaskStatus
def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None:
try:
task_manager.update_task_with_progress(
task_id, progress=5, message="Initializing..."
)
input_path = FileManager.get_input_path(task_id)
if not input_path or not input_path.exists():
raise RuntimeError(f"Input file not found for task {task_id}")
output_dir = FileManager.ensure_output_dir(task_id)
tracks = []
for entry in tracklist:
track_dict = {
"ts": entry.ts,
"tn": entry.tn or "",
"an": entry.an or "",
"al": entry.al or "",
"date": entry.date or "",
"ext": entry.ext or ""
}
tracks.append(track_dict)
from types import SimpleNamespace
args = SimpleNamespace(
format=options.get("format", "mp3"),
transcode_to=options.get("transcode_to", None),
drop_video=options.get("drop_video", False),
drop_subs=options.get("drop_subs", False),
number_tracks=options.get("number_tracks", False),
replace_bad_chars=options.get("replace_bad_chars", False),
replacement_char=options.get("replacement_char", "_"),
bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '),
skip_existing=options.get("skip_existing", False),
output_template=options.get("output_template", "%an-%tn.%ext"),
album=options.get("album", None),
comment=options.get("comment", None),
no_comment=options.get("no_comment", False),
comment_stream=options.get("comment_stream", None),
merge_comments=options.get("merge_comments", False),
comment_separator=options.get("comment_separator", "; "),
delete_original=False,
)
project_root = Path(__file__).parent.parent.parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from audio_splitter.core import split_audio
task_manager.update_task_with_progress(
task_id, progress=10, message="Starting split..."
)
# Run the split
split_audio(str(input_path), str(output_dir), tracks, args)
# Get output files
output_files = FileManager.get_output_files(task_id)
# Final status update
task_manager.update_task_with_progress(
task_id,
progress=100,
message="Split complete",
status=TaskStatus.DONE
)
# Add tracks to the task state
task_manager.update_task(
task_id,
tracks=output_files
)
# Give WebSocket time to send the final message
time.sleep(0.5)
except Exception as e:
task_manager.update_task_with_progress(
task_id,
progress=0,
message="Split failed",
status=TaskStatus.ERROR,
error=str(e)
)
+110
View File
@@ -0,0 +1,110 @@
"""In-memory task state management."""
from typing import Dict, Optional, List
from datetime import datetime, timedelta
import threading
import time
from backend.config import settings
from backend.models.response import TaskStatus, TrackInfo
from backend.services.progress_publisher import publish_progress
class TaskManager:
def __init__(self):
self._tasks: Dict[str, dict] = {}
self._lock = threading.Lock()
def create_task(self, task_id: str, filename: str, file_size: int) -> None:
with self._lock:
self._tasks[task_id] = {
"task_id": task_id,
"filename": filename,
"file_size": file_size,
"status": TaskStatus.PENDING,
"progress": 0,
"message": "Upload complete",
"error": None,
"tracks": [],
"created_at": datetime.now(),
"updated_at": datetime.now()
}
def has_task(self, task_id: str) -> bool:
return task_id in self._tasks
def get_status(self, task_id: str) -> dict:
"""Get task status with datetime objects converted to ISO format."""
with self._lock:
if task_id not in self._tasks:
return {}
# Return a copy with datetime objects converted to ISO strings
status = self._tasks[task_id].copy()
return self._prepare_status_for_serialization(status)
def _prepare_status_for_serialization(self, status: dict) -> dict:
"""Convert datetime objects to ISO format strings for JSON serialization."""
serializable = {}
for key, value in status.items():
if isinstance(value, datetime):
serializable[key] = value.isoformat()
elif isinstance(value, list):
# Handle lists of objects (e.g., tracks)
serializable[key] = [
{k: v.isoformat() if isinstance(v, datetime) else v for k, v in item.items()}
if isinstance(item, dict)
else item
for item in value
]
elif isinstance(value, dict):
# Recursively handle nested dicts
serializable[key] = {
k: v.isoformat() if isinstance(v, datetime) else v
for k, v in value.items()
}
else:
serializable[key] = value
return serializable
def update_task(self, task_id: str, **kwargs) -> None:
with self._lock:
if task_id in self._tasks:
self._tasks[task_id].update(kwargs)
self._tasks[task_id]["updated_at"] = datetime.now()
def update_task_with_progress(self, task_id: str, progress: int, message: str,
status: str = None, error: str = None) -> None:
update_kwargs = {
"progress": progress,
"message": message
}
if status:
update_kwargs["status"] = status
if error is not None:
update_kwargs["error"] = error
self.update_task(task_id, **update_kwargs)
# Publish progress via WebSocket
publish_progress(task_id, progress, message, status or "processing")
def cleanup_old_tasks(self) -> None:
now = datetime.now()
timeout = timedelta(seconds=settings.cleanup_after_seconds)
with self._lock:
to_delete = []
for task_id, data in self._tasks.items():
if now - data["created_at"] > timeout:
to_delete.append(task_id)
for task_id in to_delete:
self._delete_task_files(task_id)
del self._tasks[task_id]
def _delete_task_files(self, task_id: str) -> None:
import shutil
task_dir = settings.temp_dir / task_id
if task_dir.exists():
shutil.rmtree(task_dir, ignore_errors=True)
task_manager = TaskManager()
+1
View File
@@ -0,0 +1 @@
"""Utility functions for the web backend."""
+23
View File
@@ -0,0 +1,23 @@
"""Input validation utilities."""
import re
from typing import List, Tuple
from ..models.request import TracklistEntry
def validate_tracklist(tracklist: List[TracklistEntry]) -> Tuple[bool, List[str]]:
"""
Validate a tracklist and return any errors.
Returns:
(is_valid, error_messages)
"""
errors = []
for idx, entry in enumerate(tracklist, 1):
# Each entry must have a timestamp
if not entry.ts or not entry.ts.strip():
errors.append(f"Line {idx}: Missing timestamp (%ts)")
# Additional validation could be added here
return len(errors) == 0, errors
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
.git
.gitignore
*.log
.env
.env.local
.DS_Store
Dockerfile
Dockerfile.dev
.dockerignore
nginx.conf
+27
View File
@@ -0,0 +1,27 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json* ./
RUN npm install
# Copy the application code and build
COPY . .
RUN npm run build
# Stage 2: Production (nginx)
FROM nginx:alpine
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Expose the port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Audio Splitter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+35
View File
@@ -0,0 +1,35 @@
server {
listen 80;
server_name _;
client_max_body_size 500M;
# Root directory for static files
root /usr/share/nginx/html;
index index.html;
# Serve React app
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy WebSocket requests to backend
location /ws/ {
proxy_pass http://backend:8000/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+4239
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "audio-splitter-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.19",
"@mui/material": "^5.14.20",
"@mui/x-data-grid": "^6.18.5",
"axios": "^1.6.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.2.2",
"vite": "^5.0.8"
}
}
+127
View File
@@ -0,0 +1,127 @@
import React from 'react'
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material'
import { Box, Grid, Button, CircularProgress, Typography } from '@mui/material'
import { PlayArrow } from '@mui/icons-material'
import { Layout } from './components/Layout'
import { UploadZone } from './components/UploadZone'
import { TracklistEditor } from './components/TracklistEditor'
import { OptionsPanel } from './components/OptionsPanel'
import { ProgressDisplay } from './components/ProgressDisplay'
import { DownloadSection } from './components/DownloadSection'
import { useUploadStore } from './stores/uploadStore'
import { useTracklistStore } from './stores/tracklistStore'
import { useOptionsStore } from './stores/optionsStore'
import { useTaskStore } from './stores/taskStore'
import { useUIStore } from './stores/uiStore'
import { useValidationStore } from './stores/validationStore'
import { useWebSocket } from './hooks/useWebSocket'
import { startSplit } from './api/client'
const App: React.FC = () => {
const { theme } = useUIStore()
const { taskId } = useUploadStore()
const { entries, isValid } = useTracklistStore()
const { options } = useOptionsStore()
const {
isProcessing,
setTaskId,
setError,
setIsProcessing,
addLog,
reset,
} = useTaskStore()
const { formatError } = useValidationStore()
useWebSocket(taskId && isProcessing ? taskId : null)
const handleSplit = async () => {
if (!taskId) {
alert('Please upload a file first')
return
}
if (!isValid) {
alert('Tracklist has errors. Please fix them before splitting.')
return
}
if (entries.length === 0) {
alert('Tracklist is empty')
return
}
try {
setTaskId(taskId)
setIsProcessing(true)
addLog('🚀 Starting split...')
const response = await startSplit(taskId, entries, options)
addLog(`✅ Split task started (ID: ${response.task_id})`)
} catch (error: any) {
setError(error.response?.data?.detail || error.message || 'Failed to start split')
addLog(`❌ Error: ${error.response?.data?.detail || error.message || 'Failed to start split'}`)
setIsProcessing(false)
}
}
const handleReset = () => {
reset()
}
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError
return (
<ThemeProvider
theme={createTheme({
palette: {
mode: theme,
},
})}
>
<CssBaseline />
<Layout>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<UploadZone />
</Grid>
<Grid item xs={12} md={6}>
<TracklistEditor />
</Grid>
<Grid item xs={12} md={4}>
<OptionsPanel />
</Grid>
<Grid item xs={12} md={8}>
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
<Button
variant="contained"
color="success"
startIcon={isProcessing ? <CircularProgress size={20} color="inherit" /> : <PlayArrow />}
onClick={handleSplit}
disabled={isSplitDisabled}
sx={{ flex: 1 }}
>
{isProcessing ? 'Processing...' : 'Split'}
</Button>
<Button variant="outlined" color="secondary" onClick={handleReset} disabled={isProcessing}>
Reset
</Button>
</Box>
{formatError && (
<Box sx={{ mb: 2, p: 2, bgcolor: 'warning.light', borderRadius: 1 }}>
<Typography color="warning.dark" variant="body2">
{formatError}
</Typography>
</Box>
)}
<ProgressDisplay />
<DownloadSection />
</Grid>
</Grid>
</Layout>
</ThemeProvider>
)
}
export default App
+65
View File
@@ -0,0 +1,65 @@
import axios from 'axios'
import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
export const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => {
const formData = new FormData()
formData.append('file', file)
const response = await api.post('/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
}
export const startSplit = async (
task_id: string,
tracklist: TracklistEntry[],
options: SplitOptions
): Promise<{ task_id: string; status: string }> => {
const response = await api.post('/split', {
task_id,
tracklist,
options,
})
return response.data
}
export const getStatus = async (task_id: string): Promise<TaskStatus> => {
const response = await api.get(`/status/${task_id}`)
return response.data
}
export const getDownloadUrl = (task_id: string): string => {
return `/api/download/${task_id}`
}
export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip`
}
// New functions for format validation feature
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
const response = await api.get('/formats')
return response.data
}
export const getTaskInfo = async (task_id: string): Promise<{
task_id: string
has_audio: boolean
has_video: boolean
has_subtitle: boolean
audio_codec: string | null
}> => {
const response = await api.get(`/info/${task_id}`)
return response.data
}
@@ -0,0 +1,73 @@
import React from 'react'
import { Paper, Typography, Button, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material'
import { Download, FolderZip } from '@mui/icons-material'
import { useTaskStore } from '../stores/taskStore'
import { getDownloadZipUrl } from '../api/client'
import { formatFileSize } from '../utils/formatters'
export const DownloadSection: React.FC = () => {
const { taskId, tracks, status } = useTaskStore()
console.log('[DownloadSection] Rendering:', { status, tracks, taskId })
// Check if we should show the download section
if (status !== 'done' || !tracks || tracks.length === 0 || !taskId) {
return null
}
// If we get here, we have tracks
console.log('[DownloadSection] Showing tracks:', tracks)
const handleDownloadZip = () => {
const url = getDownloadZipUrl(taskId)
window.open(url, '_blank')
}
const handleDownloadTrack = (filename: string) => {
const url = `/api/download/${taskId}/${filename}`
window.open(url, '_blank')
}
return (
<Paper sx={{ p: 3, mt: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
📥 Download Results
</Typography>
<Button
variant="contained"
color="primary"
startIcon={<FolderZip />}
onClick={handleDownloadZip}
sx={{ mb: 2 }}
fullWidth
>
Download All as ZIP
</Button>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Individual Tracks
</Typography>
<List dense>
{tracks.map((track, index) => (
<ListItem
key={index}
secondaryAction={
<IconButton edge="end" onClick={() => handleDownloadTrack(track.filename)} size="small">
<Download />
</IconButton>
}
>
<ListItemText
primary={track.filename}
secondary={formatFileSize(track.size)}
/>
</ListItem>
))}
</List>
</Paper>
)
}
+57
View File
@@ -0,0 +1,57 @@
import React from 'react'
import { AppBar, Toolbar, Typography, IconButton, Box, Container, Badge } from '@mui/material'
import { Brightness4, Brightness7, FiberManualRecord } from '@mui/icons-material'
import { useUIStore } from '../stores/uiStore'
interface LayoutProps {
children: React.ReactNode
}
export const Layout: React.FC<LayoutProps> = ({ children }) => {
const { theme, toggleTheme, wsConnected } = useUIStore()
return (
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<AppBar position="static" color="default" elevation={1}>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
🎵 Audio Splitter
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Badge
color={wsConnected ? 'success' : 'error'}
variant="dot"
sx={{ mr: 1 }}
>
<FiberManualRecord
sx={{
fontSize: 12,
color: wsConnected ? 'green' : 'red',
visibility: 'hidden',
}}
/>
</Badge>
<Typography variant="caption" color="text.secondary">
{wsConnected ? 'Connected' : 'Disconnected'}
</Typography>
<IconButton onClick={toggleTheme} color="inherit">
{theme === 'light' ? <Brightness4 /> : <Brightness7 />}
</IconButton>
</Box>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ flex: 1, py: 4 }}>
{children}
</Container>
<Box component="footer" sx={{ py: 2, textAlign: 'center', borderTop: 1, borderColor: 'divider' }}>
<Typography variant="body2" color="text.secondary">
Audio Splitter v0.1.0 Built with
</Typography>
</Box>
</Box>
)
}
@@ -0,0 +1,307 @@
import React, { useEffect } from 'react'
import {
Box,
Paper,
Typography,
TextField,
MenuItem,
FormControlLabel,
Switch,
Collapse,
IconButton,
Divider,
Alert,
} from '@mui/material'
import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore'
interface SectionProps {
title: string
children: React.ReactNode
defaultExpanded?: boolean
}
const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = false }) => {
const [expanded, setExpanded] = React.useState(defaultExpanded)
return (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
py: 1,
}}
onClick={() => setExpanded(!expanded)}
>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{title}
</Typography>
<IconButton size="small">{expanded ? <ExpandLess /> : <ExpandMore />}</IconButton>
</Box>
<Divider />
<Collapse in={expanded}>
<Box sx={{ pt: 2, pb: 1 }}>{children}</Box>
</Collapse>
</Box>
)
}
export const OptionsPanel: React.FC = () => {
const { options, setOptions } = useOptionsStore()
const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore()
// Audio-only formats from backend constants (hardcoded for now)
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFormat = e.target.value
setOptions({ format: newFormat })
// Validate format
if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
setFormatError(
`Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
}
}
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked
setOptions({ drop_video: checked })
// Re-validate format
if (audioOnlyFormats.includes(options.format) && hasVideo && !checked) {
setFormatError(
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
)
} else {
setFormatError(null)
}
}
// Re-validate when hasVideo changes (e.g., after upload)
useEffect(() => {
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
const newError = shouldShowError
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
: null
// Only update if the error state actually changes
if (newError !== formatError) {
setFormatError(newError)
}
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
return (
<Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Options
</Typography>
{formatError && (
<Alert severity="warning" sx={{ mb: 2 }}>
{formatError}
</Alert>
)}
{/* Output Settings */}
<Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Format"
select
value={options.format}
onChange={handleFormatChange}
fullWidth
size="small"
error={!!formatError}
>
<MenuItem value="mp3">MP3</MenuItem>
<MenuItem value="m4a">M4A</MenuItem>
<MenuItem value="mkv">MKV</MenuItem>
<MenuItem value="mp4">MP4</MenuItem>
<MenuItem value="ogg">OGG</MenuItem>
<MenuItem value="opus">OPUS</MenuItem>
<MenuItem value="flac">FLAC</MenuItem>
<MenuItem value="wav">WAV</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
</TextField>
<TextField
label="Transcode to"
select
value={options.transcode_to || ''}
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
fullWidth
size="small"
>
<MenuItem value="">Copy (no transcoding)</MenuItem>
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
<MenuItem value="aac">AAC</MenuItem>
<MenuItem value="libopus">OPUS</MenuItem>
</TextField>
{hasVideo && (
<FormControlLabel
control={
<Switch
checked={options.drop_video}
onChange={handleDropVideoChange}
/>
}
label="Drop video streams"
/>
)}
<FormControlLabel
control={
<Switch
checked={options.drop_subs}
onChange={(e) => handleChange('drop_subs', e.target.checked)}
/>
}
label="Drop subtitle streams"
/>
</Box>
</Section>
{/* Filename Settings */}
<Section title="Filename Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Output template"
value={options.output_template}
onChange={(e) => handleChange('output_template', e.target.value)}
fullWidth
size="small"
helperText="Placeholders: %tn (track name), %an (author), %al (album), %date, %ext, %num"
/>
<FormControlLabel
control={
<Switch
checked={options.number_tracks}
onChange={(e) => handleChange('number_tracks', e.target.checked)}
/>
}
label="Number tracks (01 - )"
/>
<FormControlLabel
control={
<Switch
checked={options.replace_bad_chars}
onChange={(e) => handleChange('replace_bad_chars', e.target.checked)}
/>
}
label="Replace bad characters"
/>
<TextField
label="Replacement character"
value={options.replacement_char}
onChange={(e) => handleChange('replacement_char', e.target.value)}
size="small"
disabled={!options.replace_bad_chars}
/>
<TextField
label="Bad characters list"
value={options.bad_chars}
onChange={(e) => handleChange('bad_chars', e.target.value)}
fullWidth
size="small"
disabled={!options.replace_bad_chars}
/>
<FormControlLabel
control={
<Switch
checked={options.skip_existing}
onChange={(e) => handleChange('skip_existing', e.target.checked)}
/>
}
label="Skip existing files"
/>
</Box>
</Section>
{/* Metadata Settings */}
<Section title="Metadata Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Album"
value={options.album}
onChange={(e) => handleChange('album', e.target.value)}
fullWidth
size="small"
/>
<TextField
label="Comment"
value={options.comment}
onChange={(e) => handleChange('comment', e.target.value)}
fullWidth
size="small"
/>
<FormControlLabel
control={
<Switch
checked={options.no_comment}
onChange={(e) => handleChange('no_comment', e.target.checked)}
/>
}
label="No comment"
/>
<TextField
label="Comment stream index"
type="number"
value={options.comment_stream ?? ''}
onChange={(e) =>
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
}
size="small"
disabled={options.no_comment}
/>
<FormControlLabel
control={
<Switch
checked={options.merge_comments}
onChange={(e) => handleChange('merge_comments', e.target.checked)}
/>
}
label="Merge all comments"
disabled={options.no_comment}
/>
<TextField
label="Comment separator"
value={options.comment_separator}
onChange={(e) => handleChange('comment_separator', e.target.value)}
size="small"
disabled={!options.merge_comments || options.no_comment}
/>
</Box>
</Section>
{/* Tracklist Settings */}
<Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Tracklist Format"
value={options.tracklist_format}
onChange={(e) => handleChange('tracklist_format', e.target.value)}
fullWidth
size="small"
helperText="Placeholders: %ts (timestamp), %tn (track name), %an (author), %al (album), %date, %ext"
/>
</Box>
</Section>
</Paper>
)
// Helper function for option updates
function handleChange(field: string, value: any) {
setOptions({ [field]: value })
}
}
@@ -0,0 +1,107 @@
import React, { useEffect, useRef } from 'react'
import { Box, Paper, Typography, LinearProgress, Alert, Chip } from '@mui/material'
import { useTaskStore } from '../stores/taskStore'
export const ProgressDisplay: React.FC = () => {
const { status, progress, message, error, tracks, logs } = useTaskStore()
const logContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, [logs])
if (!status) {
return null
}
const getStatusColor = () => {
switch (status) {
case 'pending':
return 'info'
case 'processing':
return 'warning'
case 'done':
return 'success'
case 'error':
return 'error'
default:
return 'default'
}
}
const getStatusLabel = () => {
switch (status) {
case 'pending':
return 'Waiting'
case 'processing':
return 'Processing'
case 'done':
return 'Complete'
case 'error':
return 'Error'
default:
return 'Unknown'
}
}
return (
<Paper sx={{ p: 3 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">📊 Progress</Typography>
<Chip label={getStatusLabel()} color={getStatusColor()} size="small" />
</Box>
<Box sx={{ mb: 2 }}>
<LinearProgress
variant="determinate"
value={progress}
color={status === 'error' ? 'error' : status === 'done' ? 'success' : 'primary'}
sx={{ height: 10, borderRadius: 5 }}
/>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{progress}% {message}
</Typography>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{tracks.length > 0 && status === 'done' && (
<Alert severity="success" sx={{ mb: 2 }}>
{tracks.length} track(s) extracted successfully!
</Alert>
)}
<Box
ref={logContainerRef}
sx={{
maxHeight: 200,
overflowY: 'auto',
bgcolor: 'background.default',
p: 2,
borderRadius: 1,
fontFamily: 'monospace',
fontSize: '12px',
lineHeight: 1.6,
}}
>
{logs.length === 0 ? (
<Typography variant="caption" color="text.secondary">
Waiting for progress updates...
</Typography>
) : (
logs.map((log, index) => (
<div key={index} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{log}
</div>
))
)}
</Box>
</Paper>
)
}
@@ -0,0 +1,138 @@
import React, { useCallback, useEffect, useState } from 'react'
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore'
import { useOptionsStore } from '../stores/optionsStore'
import { parseAndValidateTracklist } from '../utils/validators'
export const TracklistEditor: React.FC = () => {
const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false)
const validate = (text: string) => {
const result = parseAndValidateTracklist(text, options.tracklist_format)
setEntries(result.entries)
setErrors(result.errors)
setIsValid(result.isValid)
}
const handleTextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = event.target.value
setRawText(text)
validate(text)
}
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
const file = acceptedFiles[0]
const reader = new FileReader()
reader.onload = (event) => {
const text = event.target?.result as string
setRawText(text)
validate(text)
setIsDragging(false)
}
reader.readAsText(file)
},
[setRawText]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'text/plain': ['.txt'],
},
multiple: false,
})
// Re-validate when tracklist format changes
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [options.tracklist_format])
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
return (
<Paper
{...getRootProps()}
sx={{
p: 3,
border: isDragActive ? '2px dashed' : '1px solid',
borderColor: isDragActive ? 'primary.main' : 'divider',
borderRadius: 2,
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
transition: 'all 0.2s ease',
}}
>
<input {...getInputProps()} />
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Tracklist
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
{lineCount} track(s)
{isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
Format: {options.tracklist_format}
</Typography>
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
{/* Line numbers column */}
<Box
sx={{
minWidth: 40,
maxWidth: 40,
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: 1.7,
color: 'text.secondary',
textAlign: 'right',
userSelect: 'none',
overflow: 'hidden',
}}
>
{rawText.split('\n').map((_, i) => (
<div key={i}>{i + 1}</div>
))}
</Box>
{/* Editor text area */}
<TextField
multiline
fullWidth
minRows={10}
maxRows={20}
value={rawText}
onChange={handleTextChange}
placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
variant="outlined"
sx={{
'& .MuiInputBase-root': {
fontFamily: 'monospace',
fontSize: '14px',
lineHeight: 1.7,
},
}}
error={!isValid && errors.length > 0}
helperText={
!isValid && errors.length > 0
? errors.map((e) => `Line ${e.line}: ${e.message}`).join('; ')
: 'Drop a .txt file here or paste your tracklist'
}
/>
</Box>
{isDragActive && (
<Alert severity="info" sx={{ mt: 2 }}>
Drop your tracklist file (.txt) here
</Alert>
)}
</Paper>
)
}
+160
View File
@@ -0,0 +1,160 @@
import React, { useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
import { useUploadStore } from '../stores/uploadStore'
import { uploadFile, getTaskInfo } from '../api/client'
import { useTaskStore } from '../stores/taskStore'
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
export const UploadZone: React.FC = () => {
const {
file,
fileName,
fileSize,
isUploading,
uploadProgress,
error,
setFile,
setFileName,
setFileSize,
setIsUploading,
setUploadProgress,
setError,
setTaskId,
setHasVideo,
setHasAudio,
setHasSubtitle,
setAudioCodec,
} = useUploadStore()
const { setTaskId: setTaskIdStore } = useTaskStore()
const onDrop = useCallback(
async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
const selectedFile = acceptedFiles[0]
const extension = '.' + selectedFile.name.split('.').pop()?.toLowerCase()
if (!ALLOWED_EXTENSIONS.includes(extension)) {
setError(`Unsupported file format. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`)
return
}
setFile(selectedFile)
setFileName(selectedFile.name)
setFileSize(selectedFile.size)
setError(null)
setIsUploading(true)
setUploadProgress(0)
try {
const response = await uploadFile(selectedFile)
const taskId = response.task_id
setTaskId(taskId)
setTaskIdStore(taskId)
setUploadProgress(100)
setIsUploading(false)
// Fetch stream info
try {
const info = await getTaskInfo(taskId)
setHasVideo(info.has_video)
setHasAudio(info.has_audio)
setHasSubtitle(info.has_subtitle)
setAudioCodec(info.audio_codec)
} catch (err) {
console.error('Failed to fetch stream info:', err)
// Don't block the upload flow if this fails; we'll just assume no video
}
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Upload failed')
setIsUploading(false)
setUploadProgress(0)
setFile(null)
setFileName('')
setFileSize(0)
}
},
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
)
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'audio/*': ALLOWED_EXTENSIONS,
},
multiple: false,
disabled: isUploading,
})
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return (
<Box>
<Paper
{...getRootProps()}
sx={{
p: 4,
border: '2px dashed',
borderColor: isDragActive ? 'primary.main' : 'grey.300',
borderRadius: 2,
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
cursor: isUploading ? 'default' : 'pointer',
transition: 'all 0.2s ease',
textAlign: 'center',
}}
>
<input {...getInputProps()} />
{file ? (
<Box>
<InsertDriveFile sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
<Typography variant="h6">{fileName}</Typography>
<Typography variant="body2" color="text.secondary">
{formatFileSize(fileSize)}
</Typography>
{isUploading && (
<Box sx={{ mt: 2, width: '100%' }}>
<LinearProgress variant="determinate" value={uploadProgress} />
<Typography variant="caption" color="text.secondary">
{uploadProgress}% uploaded
</Typography>
</Box>
)}
{!isUploading && (
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
Uploaded successfully
</Typography>
)}
</Box>
) : (
<Box>
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
<Typography variant="h6" color="text.secondary">
{isDragActive ? 'Drop your audio file here' : 'Drag & drop your audio file here'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
or click to browse
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
Supported formats: {ALLOWED_EXTENSIONS.join(', ')}
</Typography>
</Box>
)}
</Paper>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
)}
</Box>
)
}
+190
View File
@@ -0,0 +1,190 @@
import { useEffect, useRef } from 'react'
import { useTaskStore } from '../stores/taskStore'
import { useUIStore } from '../stores/uiStore'
import { getStatus } from '../api/client'
export const useWebSocket = (taskId: string | null) => {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reconnectAttempts = useRef(0)
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const {
setStatus,
setProgress,
setMessage,
setError,
setTracks,
addLog,
setIsProcessing,
status,
} = useTaskStore()
const { setWsConnected } = useUIStore()
const pollStatus = async () => {
if (!taskId) return
try {
const response = await getStatus(taskId)
console.log('[Polling] Status response:', response)
// Update all state fields together
setStatus(response.status)
setProgress(response.progress)
setMessage(response.message)
// Explicitly set tracks if present
if (response.tracks && response.tracks.length > 0) {
console.log('[Polling] Setting tracks:', response.tracks)
setTracks(response.tracks)
}
if (response.status === 'done') {
console.log('[Polling] Split complete, tracks set:', response.tracks)
setIsProcessing(false)
// Ensure tracks are set one more time (safety)
if (response.tracks && response.tracks.length > 0) {
setTracks(response.tracks)
}
return
}
if (response.status === 'error') {
setError(response.error || 'Split failed')
setIsProcessing(false)
return
}
// Still processing poll again
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
pollingRef.current = setTimeout(pollStatus, 2000)
} catch (error) {
console.error('[Polling] Error:', error)
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
pollingRef.current = setTimeout(pollStatus, 3000)
}
}
useEffect(() => {
if (!taskId) {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
setWsConnected(false)
// Clear polling
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
return
}
const connect = () => {
const wsUrl = `/ws/${taskId}`
const ws = new WebSocket(wsUrl)
ws.onopen = () => {
console.log(`WebSocket connected for task ${taskId}`)
setWsConnected(true)
reconnectAttempts.current = 0
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
// Start polling when connection is established
// This ensures we get the final status even if WebSocket fails
setTimeout(() => {
pollStatus()
}, 1000)
}
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
console.log('[WebSocket] Message:', data)
if (data.type === 'status') {
const statusData = data.data
setStatus(statusData.status)
setProgress(statusData.progress)
setMessage(statusData.message)
if (statusData.error) {
setError(statusData.error)
}
if (statusData.tracks) {
setTracks(statusData.tracks)
}
if (statusData.status === 'done' || statusData.status === 'error') {
setIsProcessing(false)
}
} else if (data.type === 'progress') {
const progressData = data.data
setStatus(progressData.status)
setProgress(progressData.progress)
setMessage(progressData.message)
if (progressData.tracks) {
setTracks(progressData.tracks)
}
if (progressData.status === 'done') {
addLog('✅ Split complete!')
setIsProcessing(false)
} else if (progressData.status === 'error') {
setError(progressData.message)
addLog(`❌ Error: ${progressData.message}`)
setIsProcessing(false)
} else {
addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
}
}
} catch (error) {
console.error('[WebSocket] Failed to parse message:', error)
}
}
ws.onclose = () => {
console.log(`WebSocket disconnected for task ${taskId}`)
setWsConnected(false)
// If task is not done and we have a taskId, start polling
// We check the status store to see if it's already done
if (taskId && status !== 'done' && status !== 'error') {
console.log('[WebSocket] Disconnected while processing, starting polling...')
setTimeout(pollStatus, 1000)
}
}
ws.onerror = (error) => {
console.error('[WebSocket] Error:', error)
// onclose will handle reconnection
}
wsRef.current = ws
}
connect()
return () => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
setWsConnected(false)
}
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing, status])
return wsRef.current
}
+18
View File
@@ -0,0 +1,18 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand'
import { SplitOptions } from '../types'
const DEFAULT_OPTIONS: SplitOptions = {
format: 'mp3',
transcode_to: '',
drop_video: false,
drop_subs: false,
number_tracks: false,
replace_bad_chars: false,
replacement_char: '_',
bad_chars: '!@#№$;:%^&?*(){}[]\\/<>+=~`\' ',
skip_existing: false,
output_template: '%an-%tn.%ext',
album: '',
comment: '',
no_comment: false,
comment_stream: null,
merge_comments: false,
comment_separator: '; ',
tracklist_format: '%ts %tn - %an', // NEW
}
interface OptionsState {
options: SplitOptions
setOptions: (options: Partial<SplitOptions>) => void
reset: () => void
}
export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS },
setOptions: (newOptions) =>
set((state) => ({
options: { ...state.options, ...newOptions },
})),
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
}))
+54
View File
@@ -0,0 +1,54 @@
import { create } from 'zustand'
import { TrackInfo } from '../types'
interface TaskState {
taskId: string | null
status: 'pending' | 'processing' | 'done' | 'error' | null
progress: number
message: string
error: string | null
tracks: TrackInfo[]
logs: string[]
isProcessing: boolean
setTaskId: (taskId: string | null) => void
setStatus: (status: 'pending' | 'processing' | 'done' | 'error' | null) => void
setProgress: (progress: number) => void
setMessage: (message: string) => void
setError: (error: string | null) => void
setTracks: (tracks: TrackInfo[]) => void
addLog: (log: string) => void
setIsProcessing: (isProcessing: boolean) => void
reset: () => void
}
export const useTaskStore = create<TaskState>((set) => ({
taskId: null,
status: null,
progress: 0,
message: '',
error: null,
tracks: [],
logs: [],
isProcessing: false,
setTaskId: (taskId) => set({ taskId }),
setStatus: (status) => set({ status }),
setProgress: (progress) => set({ progress }),
setMessage: (message) => set({ message }),
setError: (error) => set({ error }),
setTracks: (tracks) => set({ tracks }),
addLog: (log) => set((state) => ({ logs: [...state.logs, log] })),
setIsProcessing: (isProcessing) => set({ isProcessing }),
reset: () =>
set({
taskId: null,
status: null,
progress: 0,
message: '',
error: null,
tracks: [],
logs: [],
isProcessing: false,
}),
}))
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand'
import { TracklistEntry } from '../types'
interface TracklistState {
rawText: string
entries: TracklistEntry[]
errors: { line: number; message: string }[]
isValid: boolean
isDragging: boolean
setRawText: (text: string) => void
setEntries: (entries: TracklistEntry[]) => void
setErrors: (errors: { line: number; message: string }[]) => void
setIsValid: (isValid: boolean) => void
setIsDragging: (isDragging: boolean) => void
reset: () => void
}
export const useTracklistStore = create<TracklistState>((set) => ({
rawText: '',
entries: [],
errors: [],
isValid: false,
isDragging: false,
setRawText: (rawText) => set({ rawText }),
setEntries: (entries) => set({ entries }),
setErrors: (errors) => set({ errors }),
setIsValid: (isValid) => set({ isValid }),
setIsDragging: (isDragging) => set({ isDragging }),
reset: () =>
set({
rawText: '',
entries: [],
errors: [],
isValid: false,
isDragging: false,
}),
}))
+31
View File
@@ -0,0 +1,31 @@
import { create } from 'zustand'
interface UIState {
theme: 'light' | 'dark'
isSidebarOpen: boolean
wsConnected: boolean
toggleTheme: () => void
setTheme: (theme: 'light' | 'dark') => void
toggleSidebar: () => void
setSidebarOpen: (isOpen: boolean) => void
setWsConnected: (connected: boolean) => void
}
export const useUIStore = create<UIState>((set) => ({
theme: 'light',
isSidebarOpen: false,
wsConnected: false,
toggleTheme: () =>
set((state) => ({
theme: state.theme === 'light' ? 'dark' : 'light',
})),
setTheme: (theme) => set({ theme }),
toggleSidebar: () =>
set((state) => ({
isSidebarOpen: !state.isSidebarOpen,
})),
setSidebarOpen: (isSidebarOpen) => set({ isSidebarOpen }),
setWsConnected: (wsConnected) => set({ wsConnected }),
}))
+69
View File
@@ -0,0 +1,69 @@
import { create } from 'zustand'
interface UploadState {
file: File | null
taskId: string | null
fileName: string
fileSize: number
isUploading: boolean
uploadProgress: number
error: string | null
// New fields for stream info
hasVideo: boolean
hasAudio: boolean
hasSubtitle: boolean
audioCodec: string | null
setFile: (file: File | null) => void
setTaskId: (taskId: string | null) => void
setFileName: (name: string) => void
setFileSize: (size: number) => void
setIsUploading: (isUploading: boolean) => void
setUploadProgress: (progress: number) => void
setError: (error: string | null) => void
setHasVideo: (hasVideo: boolean) => void
setHasAudio: (hasAudio: boolean) => void
setHasSubtitle: (hasSubtitle: boolean) => void
setAudioCodec: (audioCodec: string | null) => void
reset: () => void
}
export const useUploadStore = create<UploadState>((set) => ({
file: null,
taskId: null,
fileName: '',
fileSize: 0,
isUploading: false,
uploadProgress: 0,
error: null,
hasVideo: false,
hasAudio: false,
hasSubtitle: false,
audioCodec: null,
setFile: (file) => set({ file }),
setTaskId: (taskId) => set({ taskId }),
setFileName: (fileName) => set({ fileName }),
setFileSize: (fileSize) => set({ fileSize }),
setIsUploading: (isUploading) => set({ isUploading }),
setUploadProgress: (uploadProgress) => set({ uploadProgress }),
setError: (error) => set({ error }),
setHasVideo: (hasVideo) => set({ hasVideo }),
setHasAudio: (hasAudio) => set({ hasAudio }),
setHasSubtitle: (hasSubtitle) => set({ hasSubtitle }),
setAudioCodec: (audioCodec) => set({ audioCodec }),
reset: () =>
set({
file: null,
taskId: null,
fileName: '',
fileSize: 0,
isUploading: false,
uploadProgress: 0,
error: null,
hasVideo: false,
hasAudio: false,
hasSubtitle: false,
audioCodec: null,
}),
}))
@@ -0,0 +1,11 @@
import { create } from 'zustand'
interface ValidationState {
formatError: string | null
setFormatError: (error: string | null) => void
}
export const useValidationStore = create<ValidationState>((set) => ({
formatError: null,
setFormatError: (error) => set({ formatError: error }),
}))
+53
View File
@@ -0,0 +1,53 @@
export interface TracklistEntry {
ts: string
tn?: string
an?: string
al?: string
date?: string
ext?: string
}
export interface TrackInfo {
filename: string
size: number
}
export interface TaskStatus {
task_id: string
status: 'pending' | 'processing' | 'done' | 'error'
progress: number
message: string
error: string | null
tracks: TrackInfo[]
}
export interface SplitOptions {
format: string
transcode_to?: string
drop_video: boolean
drop_subs: boolean
number_tracks: boolean
replace_bad_chars: boolean
replacement_char: string
bad_chars: string
skip_existing: boolean
output_template: string
album: string
comment: string
no_comment: boolean
comment_stream: number | null
merge_comments: boolean
comment_separator: string
tracklist_format: string
}
export interface UploadResponse {
task_id: string
filename: string
size: number
}
export interface SplitResponse {
task_id: string
status: string
}
+7
View File
@@ -0,0 +1,7 @@
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
+119
View File
@@ -0,0 +1,119 @@
// web/frontend/src/utils/parser.ts
import { TracklistEntry } from '../types'
type Token = { type: 'literal'; value: string } | { type: 'placeholder'; value: string }
export function parseFormat(formatStr: string): Token[] {
const validPlaceholders = new Set(['ts', 'tn', 'an', 'al', 'date', 'ext'])
const tokens: Token[] = []
let i = 0
while (i < formatStr.length) {
const ch = formatStr[i]
if (ch === '%') {
if (i + 1 < formatStr.length && formatStr[i + 1] === '%') {
tokens.push({ type: 'literal', value: '%' })
i += 2
continue
}
// match %letters
const match = formatStr.substring(i).match(/^%([a-zA-Z]+)/)
if (!match) {
throw new Error(`Invalid placeholder at position ${i}: '${formatStr.substring(i)}'`)
}
const placeholder = match[1]
if (!validPlaceholders.has(placeholder)) {
throw new Error(`Unknown placeholder '%${placeholder}'. Allowed: ${Array.from(validPlaceholders).join(', ')}`)
}
tokens.push({ type: 'placeholder', value: placeholder })
i += match[0].length
} else {
let j = i
while (j < formatStr.length && formatStr[j] !== '%') {
j++
}
tokens.push({ type: 'literal', value: formatStr.substring(i, j) })
i = j
}
}
return tokens
}
export function parseLine(line: string, tokens: Token[]): Record<string, string | null> {
line = line.trim()
if (!line) {
throw new Error('Empty line')
}
const result: Record<string, string | null> = {}
let pos = 0
for (let idx = 0; idx < tokens.length; idx++) {
const token = tokens[idx]
if (token.type === 'literal') {
const literal = token.value
if (!line.startsWith(literal, pos)) {
throw new Error(`Expected literal '${literal}' at position ${pos}, got '${line.substring(pos)}'`)
}
pos += literal.length
} else {
// placeholder
const placeholder = token.value
// If this is the last token, capture the rest
if (idx === tokens.length - 1) {
const value = line.substring(pos).trim()
result[placeholder] = value || null
pos = line.length
} else {
// Find the next literal to use as delimiter
let nextLiteral: string | null = null
for (let j = idx + 1; j < tokens.length; j++) {
if (tokens[j].type === 'literal') {
nextLiteral = tokens[j].value
break
}
}
if (nextLiteral === null) {
const value = line.substring(pos).trim()
result[placeholder] = value || null
pos = line.length
} else {
const nextPos = line.indexOf(nextLiteral, pos)
if (nextPos === -1) {
throw new Error(`Could not find literal '${nextLiteral}' after placeholder '${placeholder}'`)
}
const value = line.substring(pos, nextPos).trim()
result[placeholder] = value || null
pos = nextPos
}
}
}
}
return result
}
export function parseTracklistWithFormat(text: string, format: string): TracklistEntry[] {
const tokens = parseFormat(format)
const lines = text.split('\n').filter(line => line.trim() !== '')
const entries: TracklistEntry[] = []
for (const line of lines) {
try {
const parsed = parseLine(line, tokens)
const entry: TracklistEntry = {
ts: parsed.ts || '',
tn: parsed.tn || '',
an: parsed.an || '',
al: parsed.al || '',
date: parsed.date || '',
ext: parsed.ext || '',
}
entries.push(entry)
} catch (error) {
// We'll handle errors in the validator; just skip or mark as invalid
// For now, we'll push an empty entry with an error flag
entries.push({ ts: '', tn: line, an: '' })
}
}
return entries
}
+61
View File
@@ -0,0 +1,61 @@
// web/frontend/src/utils/validators.ts
import { TracklistEntry } from '../types'
import { parseTracklistWithFormat } from './parser'
export const validateTracklist = (
entries: TracklistEntry[]
): { isValid: boolean; errors: { line: number; message: string }[] } => {
const errors: { line: number; message: string }[] = []
entries.forEach((entry, index) => {
const lineNum = index + 1
if (!entry.ts || !entry.ts.trim()) {
errors.push({ line: lineNum, message: 'Missing timestamp (%ts)' })
} else {
// Validate timestamp format
const ts = entry.ts.trim()
if (!/^\d{1,2}:\d{2}(:\d{2})?$/.test(ts) && !/^\d{1,2}:\d{2}-\d{1,2}:\d{2}$/.test(ts)) {
errors.push({ line: lineNum, message: 'Invalid timestamp format. Expected mm:ss or mm:ss-HH:MM:SS' })
}
}
if (!entry.tn || !entry.tn.trim()) {
errors.push({ line: lineNum, message: 'Missing track name (%tn)' })
}
})
return {
isValid: errors.length === 0,
errors,
}
}
// New function that parses and validates using the format
export function parseAndValidateTracklist(text: string, format: string): {
entries: TracklistEntry[]
errors: { line: number; message: string }[]
isValid: boolean
} {
// We need to handle parsing errors gracefully.
// parseTracklistWithFormat will throw on some errors, but we can catch and mark as invalid.
try {
const entries = parseTracklistWithFormat(text, format)
const validation = validateTracklist(entries)
return {
entries,
errors: validation.errors,
isValid: validation.isValid,
}
} catch (error) {
// If parsing fails (e.g., invalid format), treat all lines as errors
const lines = text.split('\n').filter(line => line.trim() !== '')
const errors = lines.map((_, index) => ({
line: index + 1,
message: error instanceof Error ? error.message : 'Parse error',
}))
return {
entries: [],
errors,
isValid: false,
}
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Backend URL for proxy (default to localhost for local dev)
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: BACKEND_URL,
changeOrigin: true,
secure: false,
},
'/ws': {
target: BACKEND_URL.replace(/^http/, 'ws'),
ws: true,
changeOrigin: true,
secure: false,
},
},
},
})
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.104.0
uvicorn[standard]>=0.24.0
python-multipart>=0.0.6
aiofiles>=23.2.0
pydantic>=2.5.0
pydantic-settings>=2.0.0
python-dotenv>=1.0.0
websockets>=12.0