Web interface merge #1

Merged
max merged 9 commits from web-interface into main 2026-08-05 08:56:17 +04:00
13 changed files with 4499 additions and 86 deletions
Showing only changes of commit cd3ce0337b - Show all commits
+2
View File
@@ -4,3 +4,5 @@ dist/
*.egg-info/
*.pyc
test_data/
venv/
web/frontend/node_modules
+6
View File
@@ -1,11 +1,13 @@
"""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
from backend.api.websocket import router as websocket_router
from backend.services import progress_publisher # Import the module
app = FastAPI(
title="Audio Splitter Web API",
@@ -22,6 +24,10 @@ app.add_middleware(
allow_headers=["*"],
)
# Store the main event loop in the progress_publisher module
# This avoids a circular import
progress_publisher.MAIN_LOOP = asyncio.get_running_loop()
# Include routers
app.include_router(upload.router)
app.include_router(split.router)
+14 -3
View File
@@ -1,15 +1,20 @@
"""WebSocket progress publisher decouples task manager from WebSocket."""
import json
import asyncio
from typing import Dict, Set
from fastapi import WebSocket
# Active WebSocket connections
# This will be set by main.py when the app starts
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
@@ -26,10 +31,14 @@ def publish_progress(task_id: str, progress: int, message: str, status: str = "p
to_remove = set()
for websocket in active_connections.get(task_id, set()):
try:
websocket.send_json(data)
# Use the stored main loop, or fallback to getting the current loop
loop = MAIN_LOOP or asyncio.get_running_loop()
asyncio.run_coroutine_threadsafe(websocket.send_json(data), loop)
except Exception:
# Client disconnected or other error
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]:
@@ -37,12 +46,14 @@ def publish_progress(task_id: str, progress: int, message: str, status: str = "p
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]:
Binary file not shown.
+7 -13
View File
@@ -5,6 +5,7 @@ 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
@@ -65,27 +66,17 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
from audio_splitter.core import split_audio
# We need to track progress from the core.
# Since the core doesn't have a progress callback, we'll update progress
# based on track list size (approximate).
total_tracks = len(tracks)
progress_base = 10 # Starting progress after init
task_manager.update_task_with_progress(
task_id, progress=progress_base, message="Starting split..."
task_id, progress=10, message="Starting split..."
)
# Run the split
# The core prints progress to stdout, but we can't easily capture it.
# We'll update progress based on track count.
# For now, we'll report progress after the split completes.
# A more advanced implementation would capture stdout or add a callback.
split_audio(str(input_path), str(output_dir), tracks, args)
# After split completes, get the output files
# Get output files
output_files = FileManager.get_output_files(task_id)
# Final status update
task_manager.update_task_with_progress(
task_id,
progress=100,
@@ -99,6 +90,9 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
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,
+4239
View File
File diff suppressed because it is too large Load Diff
@@ -62,6 +62,20 @@ export const OptionsPanel: React.FC = () => {
Options
</Typography>
{/* Tracklist Section */}
<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>
{/* Output Section */}
<Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -233,3 +247,5 @@ export const OptionsPanel: React.FC = () => {
</Paper>
)
}
+18 -19
View File
@@ -2,29 +2,28 @@ 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 { validateTracklist, parseTracklist } from '../utils/validators'
import { useOptionsStore } from '../stores/optionsStore'
import { parseAndValidateTracklist } from '../utils/validators'
import { TracklistEntry } from '../types'
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 validate = (text: string) => {
const lines = text.split('\n').filter((line) => line.trim() !== '')
const parsed = parseTracklist(lines)
setEntries(parsed)
const validation = validateTracklist(parsed)
setErrors(validation.errors)
setIsValid(validation.isValid)
}
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
@@ -50,17 +49,14 @@ export const TracklistEditor: React.FC = () => {
multiple: false,
})
// Initial validation on mount
// Re-validate when tracklist format changes
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [])
}, [options.tracklist_format])
const getLineClassName = (lineIndex: number): string => {
const hasError = errors.some((e) => e.line === lineIndex + 1)
return hasError ? 'error-line' : ''
}
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
return (
<Paper
@@ -79,9 +75,12 @@ export const TracklistEditor: React.FC = () => {
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Tracklist
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
{rawText ? `${rawText.split('\n').filter((l) => l.trim()).length} tracks` : 'No tracks yet'}
{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 }}>
@@ -112,7 +111,7 @@ export const TracklistEditor: React.FC = () => {
maxRows={20}
value={rawText}
onChange={handleTextChange}
placeholder={`Enter your tracklist here...\n\nExample:\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
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': {
+32 -13
View File
@@ -7,7 +7,16 @@ export const useWebSocket = (taskId: string | null) => {
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttempts = useRef(0)
const { setStatus, setProgress, setMessage, setError, setTracks, addLog } = useTaskStore()
const {
setStatus,
setProgress,
setMessage,
setError,
setTracks,
addLog,
setIsProcessing,
status,
} = useTaskStore()
const { setWsConnected } = useUIStore()
useEffect(() => {
@@ -39,15 +48,19 @@ export const useWebSocket = (taskId: string | null) => {
const data = JSON.parse(event.data)
if (data.type === 'status') {
const status = data.data
setStatus(status.status)
setProgress(status.progress)
setMessage(status.message)
if (status.error) {
setError(status.error)
const statusData = data.data
setStatus(statusData.status)
setProgress(statusData.progress)
setMessage(statusData.message)
if (statusData.error) {
setError(statusData.error)
}
if (status.tracks) {
setTracks(status.tracks)
if (statusData.tracks) {
setTracks(statusData.tracks)
}
// If status is done or error, stop processing
if (statusData.status === 'done' || statusData.status === 'error') {
setIsProcessing(false)
}
} else if (data.type === 'progress') {
const progressData = data.data
@@ -56,9 +69,15 @@ export const useWebSocket = (taskId: string | null) => {
setMessage(progressData.message)
if (progressData.status === 'done') {
addLog('✅ Split complete!')
setIsProcessing(false)
// If tracks are included, update them
if (progressData.tracks) {
setTracks(progressData.tracks)
}
} else if (progressData.status === 'error') {
setError(progressData.message)
addLog(`❌ Error: ${progressData.message}`)
setIsProcessing(false)
} else {
addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
}
@@ -72,8 +91,8 @@ export const useWebSocket = (taskId: string | null) => {
console.log(`WebSocket disconnected for task ${taskId}`)
setWsConnected(false)
// Try to reconnect if the task is still processing
// We'll check status via polling if needed
// If task is still in processing state, reconnect
// We'll check via polling if needed
if (reconnectAttempts.current < 5) {
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttempts.current += 1
@@ -84,7 +103,7 @@ export const useWebSocket = (taskId: string | null) => {
ws.onerror = (error) => {
console.error('WebSocket error:', error)
// The onclose will handle reconnection
// onclose will handle reconnection
}
wsRef.current = ws
@@ -103,7 +122,7 @@ export const useWebSocket = (taskId: string | null) => {
}
setWsConnected(false)
}
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected])
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing])
return wsRef.current
}
+1
View File
@@ -18,6 +18,7 @@ const DEFAULT_OPTIONS: SplitOptions = {
comment_stream: null,
merge_comments: false,
comment_separator: '; ',
tracklist_format: '%ts %tn - %an', // NEW
}
interface OptionsState {
+1
View File
@@ -38,6 +38,7 @@ export interface SplitOptions {
comment_stream: number | null
merge_comments: boolean
comment_separator: string
tracklist_format: string
}
export interface UploadResponse {
+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
}
+34 -28
View File
@@ -1,32 +1,7 @@
import { TracklistEntry } from '../types'
// web/frontend/src/utils/validators.ts
export const parseTracklist = (lines: string[]): TracklistEntry[] => {
return lines.map((line) => {
const trimmed = line.trim()
// Try to parse as "%ts %tn - %an"
const match = trimmed.match(/^(\d{1,2}:\d{2}(:\d{2})?)\s+(.+?)\s*-\s*(.+)$/)
if (match) {
return {
ts: match[1],
tn: match[3].trim(),
an: match[4].trim(),
}
}
// Try to parse as "%ts %tn"
const simpleMatch = trimmed.match(/^(\d{1,2}:\d{2}(:\d{2})?)\s+(.+)$/)
if (simpleMatch) {
return {
ts: simpleMatch[1],
tn: simpleMatch[3].trim(),
}
}
// Fallback: just use the line as is (will be flagged as error)
return {
ts: '',
tn: trimmed,
}
})
}
import { TracklistEntry } from '../types'
import { parseTracklistWithFormat } from './parser'
export const validateTracklist = (
entries: TracklistEntry[]
@@ -54,3 +29,34 @@ export const validateTracklist = (
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,
}
}
}