Frontend and backend work together fine, complete workflow is implemented

This commit is contained in:
2026-08-01 17:04:03 +05:00
parent e87d8089bf
commit cd3ce0337b
13 changed files with 4499 additions and 86 deletions
+3 -1
View File
@@ -3,4 +3,6 @@ build/
dist/ dist/
*.egg-info/ *.egg-info/
*.pyc *.pyc
test_data/ test_data/
venv/
web/frontend/node_modules
+7 -1
View File
@@ -1,11 +1,13 @@
"""FastAPI application entry point.""" """FastAPI application entry point."""
import asyncio
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from backend.config import settings from backend.config import settings
from backend.api import upload, split, status, download from backend.api import upload, split, status, download
from backend.api.websocket import router as websocket_router from backend.api.websocket import router as websocket_router
from backend.services import progress_publisher # Import the module
app = FastAPI( app = FastAPI(
title="Audio Splitter Web API", title="Audio Splitter Web API",
@@ -22,6 +24,10 @@ app.add_middleware(
allow_headers=["*"], 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 # Include routers
app.include_router(upload.router) app.include_router(upload.router)
app.include_router(split.router) app.include_router(split.router)
@@ -37,4 +43,4 @@ async def root():
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "healthy"} return {"status": "healthy"}
+15 -4
View File
@@ -1,15 +1,20 @@
"""WebSocket progress publisher decouples task manager from WebSocket.""" """WebSocket progress publisher decouples task manager from WebSocket."""
import json import asyncio
from typing import Dict, Set from typing import Dict, Set
from fastapi import WebSocket 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]] = {} active_connections: Dict[str, Set[WebSocket]] = {}
def publish_progress(task_id: str, progress: int, message: str, status: str = "processing"): 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: if task_id not in active_connections:
return return
@@ -26,10 +31,14 @@ def publish_progress(task_id: str, progress: int, message: str, status: str = "p
to_remove = set() to_remove = set()
for websocket in active_connections.get(task_id, set()): for websocket in active_connections.get(task_id, set()):
try: 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: except Exception:
# Client disconnected or other error
to_remove.add(websocket) to_remove.add(websocket)
# Clean up disconnected clients
for websocket in to_remove: for websocket in to_remove:
active_connections[task_id].discard(websocket) active_connections[task_id].discard(websocket)
if task_id in active_connections and not active_connections[task_id]: if task_id in active_connections and not active_connections[task_id]:
@@ -37,13 +46,15 @@ def publish_progress(task_id: str, progress: int, message: str, status: str = "p
def register_connection(task_id: str, websocket: WebSocket): def register_connection(task_id: str, websocket: WebSocket):
"""Register a WebSocket connection for a task."""
if task_id not in active_connections: if task_id not in active_connections:
active_connections[task_id] = set() active_connections[task_id] = set()
active_connections[task_id].add(websocket) active_connections[task_id].add(websocket)
def unregister_connection(task_id: str, websocket: WebSocket): def unregister_connection(task_id: str, websocket: WebSocket):
"""Unregister a WebSocket connection for a task."""
if task_id in active_connections: if task_id in active_connections:
active_connections[task_id].discard(websocket) active_connections[task_id].discard(websocket)
if not active_connections[task_id]: if not active_connections[task_id]:
del active_connections[task_id] del active_connections[task_id]
Binary file not shown.
+8 -14
View File
@@ -5,6 +5,7 @@ import sys
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any from typing import List, Dict, Any
import time
from backend.config import settings from backend.config import settings
from backend.services.task_manager import task_manager 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 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_manager.update_task_with_progress(
task_id, progress=progress_base, message="Starting split..." task_id, progress=10, message="Starting split..."
) )
# Run the 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) 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) output_files = FileManager.get_output_files(task_id)
# Final status update
task_manager.update_task_with_progress( task_manager.update_task_with_progress(
task_id, task_id,
progress=100, progress=100,
@@ -99,6 +90,9 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
tracks=output_files tracks=output_files
) )
# Give WebSocket time to send the final message
time.sleep(0.5)
except Exception as e: except Exception as e:
task_manager.update_task_with_progress( task_manager.update_task_with_progress(
task_id, task_id,
@@ -106,4 +100,4 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
message="Split failed", message="Split failed",
status=TaskStatus.ERROR, status=TaskStatus.ERROR,
error=str(e) error=str(e)
) )
+4239
View File
File diff suppressed because it is too large Load Diff
+17 -1
View File
@@ -62,6 +62,20 @@ export const OptionsPanel: React.FC = () => {
Options Options
</Typography> </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 */} {/* Output Section */}
<Section title="Output Settings" defaultExpanded> <Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -232,4 +246,6 @@ export const OptionsPanel: React.FC = () => {
</Section> </Section>
</Paper> </Paper>
) )
} }
+19 -20
View File
@@ -2,29 +2,28 @@ import React, { useCallback, useEffect, useState } from 'react'
import { Box, Paper, TextField, Typography, Alert } from '@mui/material' import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore' 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' import { TracklistEntry } from '../types'
export const TracklistEditor: React.FC = () => { export const TracklistEditor: React.FC = () => {
const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore() const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false) 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 handleTextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = event.target.value const text = event.target.value
setRawText(text) setRawText(text)
validate(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( const onDrop = useCallback(
(acceptedFiles: File[]) => { (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return if (acceptedFiles.length === 0) return
@@ -50,17 +49,14 @@ export const TracklistEditor: React.FC = () => {
multiple: false, multiple: false,
}) })
// Initial validation on mount // Re-validate when tracklist format changes
useEffect(() => { useEffect(() => {
if (rawText) { if (rawText) {
validate(rawText) validate(rawText)
} }
}, []) }, [options.tracklist_format])
const getLineClassName = (lineIndex: number): string => { const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
const hasError = errors.some((e) => e.line === lineIndex + 1)
return hasError ? 'error-line' : ''
}
return ( return (
<Paper <Paper
@@ -79,9 +75,12 @@ export const TracklistEditor: React.FC = () => {
<Typography variant="subtitle1" sx={{ mb: 2 }}> <Typography variant="subtitle1" sx={{ mb: 2 }}>
Tracklist Tracklist
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}> <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)` : ''} {isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
</Typography> </Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
Format: {options.tracklist_format}
</Typography>
</Typography> </Typography>
<Box sx={{ display: 'flex', gap: 2 }}> <Box sx={{ display: 'flex', gap: 2 }}>
@@ -112,7 +111,7 @@ export const TracklistEditor: React.FC = () => {
maxRows={20} maxRows={20}
value={rawText} value={rawText}
onChange={handleTextChange} 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" variant="outlined"
sx={{ sx={{
'& .MuiInputBase-root': { '& .MuiInputBase-root': {
@@ -137,4 +136,4 @@ export const TracklistEditor: React.FC = () => {
)} )}
</Paper> </Paper>
) )
} }
+33 -14
View File
@@ -7,7 +7,16 @@ export const useWebSocket = (taskId: string | null) => {
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null) const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttempts = useRef(0) 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() const { setWsConnected } = useUIStore()
useEffect(() => { useEffect(() => {
@@ -39,15 +48,19 @@ export const useWebSocket = (taskId: string | null) => {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
if (data.type === 'status') { if (data.type === 'status') {
const status = data.data const statusData = data.data
setStatus(status.status) setStatus(statusData.status)
setProgress(status.progress) setProgress(statusData.progress)
setMessage(status.message) setMessage(statusData.message)
if (status.error) { if (statusData.error) {
setError(status.error) setError(statusData.error)
} }
if (status.tracks) { if (statusData.tracks) {
setTracks(status.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') { } else if (data.type === 'progress') {
const progressData = data.data const progressData = data.data
@@ -56,9 +69,15 @@ export const useWebSocket = (taskId: string | null) => {
setMessage(progressData.message) setMessage(progressData.message)
if (progressData.status === 'done') { if (progressData.status === 'done') {
addLog('✅ Split complete!') addLog('✅ Split complete!')
setIsProcessing(false)
// If tracks are included, update them
if (progressData.tracks) {
setTracks(progressData.tracks)
}
} else if (progressData.status === 'error') { } else if (progressData.status === 'error') {
setError(progressData.message) setError(progressData.message)
addLog(`❌ Error: ${progressData.message}`) addLog(`❌ Error: ${progressData.message}`)
setIsProcessing(false)
} else { } else {
addLog(`🔄 ${progressData.message} (${progressData.progress}%)`) addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
} }
@@ -72,8 +91,8 @@ export const useWebSocket = (taskId: string | null) => {
console.log(`WebSocket disconnected for task ${taskId}`) console.log(`WebSocket disconnected for task ${taskId}`)
setWsConnected(false) setWsConnected(false)
// Try to reconnect if the task is still processing // If task is still in processing state, reconnect
// We'll check status via polling if needed // We'll check via polling if needed
if (reconnectAttempts.current < 5) { if (reconnectAttempts.current < 5) {
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttempts.current += 1 reconnectAttempts.current += 1
@@ -84,7 +103,7 @@ export const useWebSocket = (taskId: string | null) => {
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket error:', error) console.error('WebSocket error:', error)
// The onclose will handle reconnection // onclose will handle reconnection
} }
wsRef.current = ws wsRef.current = ws
@@ -103,7 +122,7 @@ export const useWebSocket = (taskId: string | null) => {
} }
setWsConnected(false) setWsConnected(false)
} }
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected]) }, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing])
return wsRef.current return wsRef.current
} }
+2 -1
View File
@@ -18,6 +18,7 @@ const DEFAULT_OPTIONS: SplitOptions = {
comment_stream: null, comment_stream: null,
merge_comments: false, merge_comments: false,
comment_separator: '; ', comment_separator: '; ',
tracklist_format: '%ts %tn - %an', // NEW
} }
interface OptionsState { interface OptionsState {
@@ -33,4 +34,4 @@ export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...state.options, ...newOptions }, options: { ...state.options, ...newOptions },
})), })),
reset: () => set({ options: { ...DEFAULT_OPTIONS } }), reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
})) }))
+2 -1
View File
@@ -38,6 +38,7 @@ export interface SplitOptions {
comment_stream: number | null comment_stream: number | null
merge_comments: boolean merge_comments: boolean
comment_separator: string comment_separator: string
tracklist_format: string
} }
export interface UploadResponse { export interface UploadResponse {
@@ -49,4 +50,4 @@ export interface UploadResponse {
export interface SplitResponse { export interface SplitResponse {
task_id: string task_id: string
status: string status: string
} }
+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
}
+35 -29
View File
@@ -1,32 +1,7 @@
import { TracklistEntry } from '../types' // web/frontend/src/utils/validators.ts
export const parseTracklist = (lines: string[]): TracklistEntry[] => { import { TracklistEntry } from '../types'
return lines.map((line) => { import { parseTracklistWithFormat } from './parser'
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,
}
})
}
export const validateTracklist = ( export const validateTracklist = (
entries: TracklistEntry[] entries: TracklistEntry[]
@@ -53,4 +28,35 @@ export const validateTracklist = (
isValid: errors.length === 0, isValid: errors.length === 0,
errors, 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,
}
}
}