From e87d8089bf6c0ad77abdf84d739e928cba7d1e46 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Sat, 1 Aug 2026 08:41:25 +0000 Subject: [PATCH] Simple frontend added, tests are required --- web/frontend/index.html | 13 + web/frontend/package.json | 36 +++ web/frontend/src/App.tsx | 118 +++++++++ web/frontend/src/api/client.ts | 47 ++++ .../src/components/DownloadSection.tsx | 67 +++++ web/frontend/src/components/Layout.tsx | 57 +++++ web/frontend/src/components/OptionsPanel.tsx | 235 ++++++++++++++++++ .../src/components/ProgressDisplay.tsx | 107 ++++++++ .../src/components/TracklistEditor.tsx | 140 +++++++++++ web/frontend/src/components/UploadZone.tsx | 143 +++++++++++ web/frontend/src/hooks/useWebSocket.ts | 109 ++++++++ web/frontend/src/index.css | 18 ++ web/frontend/src/main.tsx | 10 + web/frontend/src/stores/optionsStore.ts | 36 +++ web/frontend/src/stores/taskStore.ts | 54 ++++ web/frontend/src/stores/tracklistStore.ts | 39 +++ web/frontend/src/stores/uiStore.ts | 31 +++ web/frontend/src/stores/uploadStore.ts | 48 ++++ web/frontend/src/types/index.ts | 52 ++++ web/frontend/src/utils/formatters.ts | 7 + web/frontend/src/utils/validators.ts | 56 +++++ web/frontend/tsconfig.json | 21 ++ web/frontend/tsconfig.node.json | 10 + web/frontend/vite.config.ts | 19 ++ 24 files changed, 1473 insertions(+) create mode 100644 web/frontend/index.html create mode 100644 web/frontend/package.json create mode 100644 web/frontend/src/App.tsx create mode 100644 web/frontend/src/api/client.ts create mode 100644 web/frontend/src/components/DownloadSection.tsx create mode 100644 web/frontend/src/components/Layout.tsx create mode 100644 web/frontend/src/components/OptionsPanel.tsx create mode 100644 web/frontend/src/components/ProgressDisplay.tsx create mode 100644 web/frontend/src/components/TracklistEditor.tsx create mode 100644 web/frontend/src/components/UploadZone.tsx create mode 100644 web/frontend/src/hooks/useWebSocket.ts create mode 100644 web/frontend/src/index.css create mode 100644 web/frontend/src/main.tsx create mode 100644 web/frontend/src/stores/optionsStore.ts create mode 100644 web/frontend/src/stores/taskStore.ts create mode 100644 web/frontend/src/stores/tracklistStore.ts create mode 100644 web/frontend/src/stores/uiStore.ts create mode 100644 web/frontend/src/stores/uploadStore.ts create mode 100644 web/frontend/src/types/index.ts create mode 100644 web/frontend/src/utils/formatters.ts create mode 100644 web/frontend/src/utils/validators.ts create mode 100644 web/frontend/tsconfig.json create mode 100644 web/frontend/tsconfig.node.json create mode 100644 web/frontend/vite.config.ts 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 */} + + + + + + + {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 + + + + + + + + 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" + > + MP3 + M4A + MKV + MP4 + OGG + OPUS + FLAC + WAV + AAC + + + handleChange('transcode_to', e.target.value || undefined)} + fullWidth + size="small" + > + Copy (no transcoding) + MP3 (LAME) + AAC + OPUS + + + 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