diff --git a/web/frontend/index.html b/web/frontend/index.html
new file mode 100644
index 0000000..0b7f522
--- /dev/null
+++ b/web/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Audio Splitter
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web/frontend/package.json b/web/frontend/package.json
new file mode 100644
index 0000000..d9877bc
--- /dev/null
+++ b/web/frontend/package.json
@@ -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"
+ }
+}
\ No newline at end of file
diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx
new file mode 100644
index 0000000..e70e399
--- /dev/null
+++ b/web/frontend/src/App.tsx
@@ -0,0 +1,118 @@
+import React from 'react'
+import { ThemeProvider, createTheme, CssBaseline } from '@mui/material'
+import { Box, Grid, Button, Alert, CircularProgress } 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 { useWebSocket } from './hooks/useWebSocket'
+import { startSplit } from './api/client'
+
+const App: React.FC = () => {
+ const { theme } = useUIStore()
+ const { taskId, file } = useUploadStore()
+ const { entries, isValid } = useTracklistStore()
+ const { options } = useOptionsStore()
+ const { isProcessing, status, setStatus, setProgress, setMessage, setError, setIsProcessing, addLog, reset } =
+ useTaskStore()
+
+ // Connect WebSocket when taskId is available and processing
+ 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 {
+ 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
+
+ return (
+
+
+
+
+ {/* Upload Section */}
+
+
+
+
+ {/* Tracklist Editor */}
+
+
+
+
+ {/* Options Panel */}
+
+
+
+
+ {/* Progress / Split Controls */}
+
+
+ : }
+ onClick={handleSplit}
+ disabled={isSplitDisabled}
+ sx={{ flex: 1 }}
+ >
+ {isProcessing ? 'Processing...' : 'Split'}
+
+
+
+
+ {status && }
+ {status === 'done' && }
+
+
+
+
+ )
+}
+
+export default App
\ No newline at end of file
diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts
new file mode 100644
index 0000000..bf09776
--- /dev/null
+++ b/web/frontend/src/api/client.ts
@@ -0,0 +1,47 @@
+import axios from 'axios'
+
+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 => {
+ 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`
+}
\ No newline at end of file
diff --git a/web/frontend/src/components/DownloadSection.tsx b/web/frontend/src/components/DownloadSection.tsx
new file mode 100644
index 0000000..86d1f60
--- /dev/null
+++ b/web/frontend/src/components/DownloadSection.tsx
@@ -0,0 +1,67 @@
+import React from 'react'
+import { Box, Paper, Typography, Button, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material'
+import { Download, FileDownload, FolderZip } from '@mui/icons-material'
+import { useTaskStore } from '../stores/taskStore'
+import { getDownloadUrl, getDownloadZipUrl } from '../api/client'
+import { formatFileSize } from '../utils/formatters'
+
+export const DownloadSection: React.FC = () => {
+ const { taskId, tracks, status } = useTaskStore()
+
+ if (status !== 'done' || tracks.length === 0) {
+ return null
+ }
+
+ 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 (
+
+
+ 📥 Download Results
+
+
+ }
+ onClick={handleDownloadZip}
+ sx={{ mb: 2 }}
+ fullWidth
+ >
+ Download All as ZIP
+
+
+
+
+
+ Individual Tracks
+
+
+
+ {tracks.map((track, index) => (
+ handleDownloadTrack(track.filename)} size="small">
+
+
+ }
+ >
+
+
+ ))}
+
+
+ )
+}
\ No newline at end of file
diff --git a/web/frontend/src/components/Layout.tsx b/web/frontend/src/components/Layout.tsx
new file mode 100644
index 0000000..975af0b
--- /dev/null
+++ b/web/frontend/src/components/Layout.tsx
@@ -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 = ({ children }) => {
+ const { theme, toggleTheme, wsConnected } = useUIStore()
+
+ return (
+
+
+
+
+ 🎵 Audio Splitter
+
+
+
+
+
+
+
+ {wsConnected ? 'Connected' : 'Disconnected'}
+
+
+
+ {theme === 'light' ? : }
+
+
+
+
+
+
+ {children}
+
+
+
+
+ Audio Splitter v0.1.0 • Built with ❤️
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/web/frontend/src/components/OptionsPanel.tsx b/web/frontend/src/components/OptionsPanel.tsx
new file mode 100644
index 0000000..5e5bd8a
--- /dev/null
+++ b/web/frontend/src/components/OptionsPanel.tsx
@@ -0,0 +1,235 @@
+import React from 'react'
+import {
+ Box,
+ Paper,
+ Typography,
+ TextField,
+ Select,
+ MenuItem,
+ FormControlLabel,
+ Switch,
+ Collapse,
+ IconButton,
+ Divider,
+} from '@mui/material'
+import { ExpandMore, ExpandLess } from '@mui/icons-material'
+import { useOptionsStore } from '../stores/optionsStore'
+
+interface SectionProps {
+ title: string
+ children: React.ReactNode
+ defaultExpanded?: boolean
+}
+
+const Section: React.FC = ({ title, children, defaultExpanded = false }) => {
+ const [expanded, setExpanded] = React.useState(defaultExpanded)
+
+ return (
+
+ setExpanded(!expanded)}
+ >
+
+ {title}
+
+ {expanded ? : }
+
+
+
+ {children}
+
+
+ )
+}
+
+export const OptionsPanel: React.FC = () => {
+ const { options, setOptions } = useOptionsStore()
+
+ const handleChange = (field: string, value: any) => {
+ setOptions({ [field]: value })
+ }
+
+ return (
+
+
+ ⚙️ Options
+
+
+ {/* Output Section */}
+
+
+ handleChange('format', e.target.value)}
+ fullWidth
+ size="small"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ handleChange('transcode_to', e.target.value || undefined)}
+ fullWidth
+ size="small"
+ >
+
+
+
+
+
+
+ handleChange('drop_video', e.target.checked)}
+ />
+ }
+ label="Drop video streams"
+ />
+ handleChange('drop_subs', e.target.checked)}
+ />
+ }
+ label="Drop subtitle streams"
+ />
+
+
+
+ {/* Filename Section */}
+
+
+ handleChange('output_template', e.target.value)}
+ fullWidth
+ size="small"
+ helperText="Placeholders: %tn (track name), %an (author), %al (album), %date, %ext, %num"
+ />
+ handleChange('number_tracks', e.target.checked)}
+ />
+ }
+ label="Number tracks (01 - )"
+ />
+ handleChange('replace_bad_chars', e.target.checked)}
+ />
+ }
+ label="Replace bad characters"
+ />
+ handleChange('replacement_char', e.target.value)}
+ size="small"
+ disabled={!options.replace_bad_chars}
+ />
+ handleChange('bad_chars', e.target.value)}
+ fullWidth
+ size="small"
+ disabled={!options.replace_bad_chars}
+ />
+ handleChange('skip_existing', e.target.checked)}
+ />
+ }
+ label="Skip existing files"
+ />
+
+
+
+ {/* Metadata Section */}
+
+
+ handleChange('album', e.target.value)}
+ fullWidth
+ size="small"
+ />
+ handleChange('comment', e.target.value)}
+ fullWidth
+ size="small"
+ />
+ handleChange('no_comment', e.target.checked)}
+ />
+ }
+ label="No comment"
+ />
+
+ handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
+ }
+ size="small"
+ disabled={options.no_comment}
+ />
+ handleChange('merge_comments', e.target.checked)}
+ />
+ }
+ label="Merge all comments"
+ disabled={options.no_comment}
+ />
+ handleChange('comment_separator', e.target.value)}
+ size="small"
+ disabled={!options.merge_comments || options.no_comment}
+ />
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/web/frontend/src/components/ProgressDisplay.tsx b/web/frontend/src/components/ProgressDisplay.tsx
new file mode 100644
index 0000000..86fb5d0
--- /dev/null
+++ b/web/frontend/src/components/ProgressDisplay.tsx
@@ -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(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 (
+
+
+ 📊 Progress
+
+
+
+
+
+
+ {progress}% – {message}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {tracks.length > 0 && status === 'done' && (
+
+ ✅ {tracks.length} track(s) extracted successfully!
+
+ )}
+
+
+ {logs.length === 0 ? (
+
+ Waiting for progress updates...
+
+ ) : (
+ logs.map((log, index) => (
+
+ {log}
+
+ ))
+ )}
+
+
+ )
+}
\ No newline at end of file
diff --git a/web/frontend/src/components/TracklistEditor.tsx b/web/frontend/src/components/TracklistEditor.tsx
new file mode 100644
index 0000000..32f594a
--- /dev/null
+++ b/web/frontend/src/components/TracklistEditor.tsx
@@ -0,0 +1,140 @@
+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 { TracklistEntry } from '../types'
+
+export const TracklistEditor: React.FC = () => {
+ const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
+ const [isDragging, setIsDragging] = useState(false)
+
+ const handleTextChange = (event: React.ChangeEvent) => {
+ 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
+
+ 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,
+ })
+
+ // Initial validation on mount
+ useEffect(() => {
+ if (rawText) {
+ validate(rawText)
+ }
+ }, [])
+
+ const getLineClassName = (lineIndex: number): string => {
+ const hasError = errors.some((e) => e.line === lineIndex + 1)
+ return hasError ? 'error-line' : ''
+ }
+
+ return (
+
+
+
+
+ Tracklist
+
+ {rawText ? `${rawText.split('\n').filter((l) => l.trim()).length} tracks` : 'No tracks yet'}
+ {isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
+
+
+
+
+ {/* Line numbers column */}
+
+ {rawText.split('\n').map((_, i) => (
+ {i + 1}
+ ))}
+
+
+ {/* Editor text area */}
+ 0}
+ helperText={
+ !isValid && errors.length > 0
+ ? errors.map((e) => `Line ${e.line}: ${e.message}`).join('; ')
+ : 'Drop a .txt file here or paste your tracklist'
+ }
+ />
+
+
+ {isDragActive && (
+
+ Drop your tracklist file (.txt) here
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/web/frontend/src/components/UploadZone.tsx b/web/frontend/src/components/UploadZone.tsx
new file mode 100644
index 0000000..3721846
--- /dev/null
+++ b/web/frontend/src/components/UploadZone.tsx
@@ -0,0 +1,143 @@
+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 } 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,
+ } = useUploadStore()
+
+ const { setTaskId: setTaskIdStore, setIsProcessing } = 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)
+ setTaskId(response.task_id)
+ setTaskIdStore(response.task_id)
+ setUploadProgress(100)
+ setIsUploading(false)
+ } 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 (
+
+
+
+
+ {file ? (
+
+
+ {fileName}
+
+ {formatFileSize(fileSize)}
+
+ {isUploading && (
+
+
+
+ {uploadProgress}% uploaded
+
+
+ )}
+ {!isUploading && (
+
+ ✅ Uploaded successfully
+
+ )}
+
+ ) : (
+
+
+
+ {isDragActive ? 'Drop your audio file here' : 'Drag & drop your audio file here'}
+
+
+ or click to browse
+
+
+ Supported formats: {ALLOWED_EXTENSIONS.join(', ')}
+
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/web/frontend/src/hooks/useWebSocket.ts b/web/frontend/src/hooks/useWebSocket.ts
new file mode 100644
index 0000000..d096f3e
--- /dev/null
+++ b/web/frontend/src/hooks/useWebSocket.ts
@@ -0,0 +1,109 @@
+import { useEffect, useRef } from 'react'
+import { useTaskStore } from '../stores/taskStore'
+import { useUIStore } from '../stores/uiStore'
+
+export const useWebSocket = (taskId: string | null) => {
+ const wsRef = useRef(null)
+ const reconnectTimeoutRef = useRef(null)
+ const reconnectAttempts = useRef(0)
+
+ const { setStatus, setProgress, setMessage, setError, setTracks, addLog } = useTaskStore()
+ const { setWsConnected } = useUIStore()
+
+ useEffect(() => {
+ if (!taskId) {
+ if (wsRef.current) {
+ wsRef.current.close()
+ wsRef.current = null
+ }
+ setWsConnected(false)
+ 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
+ }
+ }
+
+ ws.onmessage = (event) => {
+ try {
+ 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)
+ }
+ if (status.tracks) {
+ setTracks(status.tracks)
+ }
+ } else if (data.type === 'progress') {
+ const progressData = data.data
+ setStatus(progressData.status)
+ setProgress(progressData.progress)
+ setMessage(progressData.message)
+ if (progressData.status === 'done') {
+ addLog('✅ Split complete!')
+ } else if (progressData.status === 'error') {
+ setError(progressData.message)
+ addLog(`❌ Error: ${progressData.message}`)
+ } else {
+ addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
+ }
+ }
+ } catch (error) {
+ console.error('Failed to parse WebSocket message:', error)
+ }
+ }
+
+ ws.onclose = () => {
+ 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 (reconnectAttempts.current < 5) {
+ reconnectTimeoutRef.current = setTimeout(() => {
+ reconnectAttempts.current += 1
+ connect()
+ }, 3000)
+ }
+ }
+
+ ws.onerror = (error) => {
+ console.error('WebSocket error:', error)
+ // The 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
+ }
+ setWsConnected(false)
+ }
+ }, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected])
+
+ return wsRef.current
+}
\ No newline at end of file
diff --git a/web/frontend/src/index.css b/web/frontend/src/index.css
new file mode 100644
index 0000000..5866aa1
--- /dev/null
+++ b/web/frontend/src/index.css
@@ -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;
+}
\ No newline at end of file
diff --git a/web/frontend/src/main.tsx b/web/frontend/src/main.tsx
new file mode 100644
index 0000000..cbe1cdf
--- /dev/null
+++ b/web/frontend/src/main.tsx
@@ -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(
+
+
+ ,
+)
\ No newline at end of file
diff --git a/web/frontend/src/stores/optionsStore.ts b/web/frontend/src/stores/optionsStore.ts
new file mode 100644
index 0000000..65eb483
--- /dev/null
+++ b/web/frontend/src/stores/optionsStore.ts
@@ -0,0 +1,36 @@
+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: '; ',
+}
+
+interface OptionsState {
+ options: SplitOptions
+ setOptions: (options: Partial) => void
+ reset: () => void
+}
+
+export const useOptionsStore = create((set) => ({
+ options: { ...DEFAULT_OPTIONS },
+ setOptions: (newOptions) =>
+ set((state) => ({
+ options: { ...state.options, ...newOptions },
+ })),
+ reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
+}))
\ No newline at end of file
diff --git a/web/frontend/src/stores/taskStore.ts b/web/frontend/src/stores/taskStore.ts
new file mode 100644
index 0000000..205de06
--- /dev/null
+++ b/web/frontend/src/stores/taskStore.ts
@@ -0,0 +1,54 @@
+import { create } from 'zustand'
+import { TaskStatus, 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((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,
+ }),
+}))
\ No newline at end of file
diff --git a/web/frontend/src/stores/tracklistStore.ts b/web/frontend/src/stores/tracklistStore.ts
new file mode 100644
index 0000000..5631fc3
--- /dev/null
+++ b/web/frontend/src/stores/tracklistStore.ts
@@ -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((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,
+ }),
+}))
\ No newline at end of file
diff --git a/web/frontend/src/stores/uiStore.ts b/web/frontend/src/stores/uiStore.ts
new file mode 100644
index 0000000..250c6ec
--- /dev/null
+++ b/web/frontend/src/stores/uiStore.ts
@@ -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((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 }),
+}))
\ No newline at end of file
diff --git a/web/frontend/src/stores/uploadStore.ts b/web/frontend/src/stores/uploadStore.ts
new file mode 100644
index 0000000..addc23e
--- /dev/null
+++ b/web/frontend/src/stores/uploadStore.ts
@@ -0,0 +1,48 @@
+import { create } from 'zustand'
+
+interface UploadState {
+ file: File | null
+ taskId: string | null
+ fileName: string
+ fileSize: number
+ isUploading: boolean
+ uploadProgress: number
+ error: 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
+ reset: () => void
+}
+
+export const useUploadStore = create((set) => ({
+ file: null,
+ taskId: null,
+ fileName: '',
+ fileSize: 0,
+ isUploading: false,
+ uploadProgress: 0,
+ error: 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 }),
+ reset: () =>
+ set({
+ file: null,
+ taskId: null,
+ fileName: '',
+ fileSize: 0,
+ isUploading: false,
+ uploadProgress: 0,
+ error: null,
+ }),
+}))
\ No newline at end of file
diff --git a/web/frontend/src/types/index.ts b/web/frontend/src/types/index.ts
new file mode 100644
index 0000000..9e1ea38
--- /dev/null
+++ b/web/frontend/src/types/index.ts
@@ -0,0 +1,52 @@
+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
+}
+
+export interface UploadResponse {
+ task_id: string
+ filename: string
+ size: number
+}
+
+export interface SplitResponse {
+ task_id: string
+ status: string
+}
\ No newline at end of file
diff --git a/web/frontend/src/utils/formatters.ts b/web/frontend/src/utils/formatters.ts
new file mode 100644
index 0000000..6075966
--- /dev/null
+++ b/web/frontend/src/utils/formatters.ts
@@ -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]
+}
\ No newline at end of file
diff --git a/web/frontend/src/utils/validators.ts b/web/frontend/src/utils/validators.ts
new file mode 100644
index 0000000..2f40db5
--- /dev/null
+++ b/web/frontend/src/utils/validators.ts
@@ -0,0 +1,56 @@
+import { TracklistEntry } from '../types'
+
+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,
+ }
+ })
+}
+
+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,
+ }
+}
\ No newline at end of file
diff --git a/web/frontend/tsconfig.json b/web/frontend/tsconfig.json
new file mode 100644
index 0000000..d0104ed
--- /dev/null
+++ b/web/frontend/tsconfig.json
@@ -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": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
\ No newline at end of file
diff --git a/web/frontend/tsconfig.node.json b/web/frontend/tsconfig.node.json
new file mode 100644
index 0000000..099658c
--- /dev/null
+++ b/web/frontend/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts"]
+}
\ No newline at end of file
diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts
new file mode 100644
index 0000000..8f98b99
--- /dev/null
+++ b/web/frontend/vite.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ server: {
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8000',
+ changeOrigin: true,
+ },
+ '/ws': {
+ target: 'ws://localhost:8000',
+ ws: true,
+ changeOrigin: true,
+ },
+ },
+ },
+})
\ No newline at end of file