Web interface merge #1
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Audio Splitter</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<ThemeProvider
|
||||||
|
theme={createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: theme,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<CssBaseline />
|
||||||
|
<Layout>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{/* Upload Section */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<UploadZone />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Tracklist Editor */}
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<TracklistEditor />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Options Panel */}
|
||||||
|
<Grid item xs={12} md={4}>
|
||||||
|
<OptionsPanel />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Progress / Split Controls */}
|
||||||
|
<Grid item xs={12} md={8}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="success"
|
||||||
|
startIcon={isProcessing ? <CircularProgress size={20} color="inherit" /> : <PlayArrow />}
|
||||||
|
onClick={handleSplit}
|
||||||
|
disabled={isSplitDisabled}
|
||||||
|
sx={{ flex: 1 }}
|
||||||
|
>
|
||||||
|
{isProcessing ? 'Processing...' : 'Split'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" color="secondary" onClick={handleReset} disabled={isProcessing}>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{status && <ProgressDisplay />}
|
||||||
|
{status === 'done' && <DownloadSection />}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Layout>
|
||||||
|
</ThemeProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -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<TaskStatus> => {
|
||||||
|
const response = await api.get(`/status/${task_id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDownloadUrl = (task_id: string): string => {
|
||||||
|
return `/api/download/${task_id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDownloadZipUrl = (task_id: string): string => {
|
||||||
|
return `/api/download/${task_id}/splits.zip`
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
|
📥 Download Results
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
startIcon={<FolderZip />}
|
||||||
|
onClick={handleDownloadZip}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Download All as ZIP
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Divider sx={{ my: 2 }} />
|
||||||
|
|
||||||
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
|
Individual Tracks
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<List dense>
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<ListItem
|
||||||
|
key={index}
|
||||||
|
secondaryAction={
|
||||||
|
<IconButton edge="end" onClick={() => handleDownloadTrack(track.filename)} size="small">
|
||||||
|
<Download />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItemText
|
||||||
|
primary={track.filename}
|
||||||
|
secondary={formatFileSize(track.size)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { AppBar, Toolbar, Typography, IconButton, Box, Container, Badge } from '@mui/material'
|
||||||
|
import { Brightness4, Brightness7, FiberManualRecord } from '@mui/icons-material'
|
||||||
|
import { useUIStore } from '../stores/uiStore'
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||||
|
const { theme, toggleTheme, wsConnected } = useUIStore()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||||
|
<AppBar position="static" color="default" elevation={1}>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" component="div" sx={{ flexGrow: 1, fontWeight: 600 }}>
|
||||||
|
🎵 Audio Splitter
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Badge
|
||||||
|
color={wsConnected ? 'success' : 'error'}
|
||||||
|
variant="dot"
|
||||||
|
sx={{ mr: 1 }}
|
||||||
|
>
|
||||||
|
<FiberManualRecord
|
||||||
|
sx={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: wsConnected ? 'green' : 'red',
|
||||||
|
visibility: 'hidden',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Badge>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{wsConnected ? 'Connected' : 'Disconnected'}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<IconButton onClick={toggleTheme} color="inherit">
|
||||||
|
{theme === 'light' ? <Brightness4 /> : <Brightness7 />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<Container maxWidth="lg" sx={{ flex: 1, py: 4 }}>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Box component="footer" sx={{ py: 2, textAlign: 'center', borderTop: 1, borderColor: 'divider' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Audio Splitter v0.1.0 • Built with ❤️
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<SectionProps> = ({ title, children, defaultExpanded = false }) => {
|
||||||
|
const [expanded, setExpanded] = React.useState(defaultExpanded)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
cursor: 'pointer',
|
||||||
|
py: 1,
|
||||||
|
}}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
<IconButton size="small">{expanded ? <ExpandLess /> : <ExpandMore />}</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
<Collapse in={expanded}>
|
||||||
|
<Box sx={{ pt: 2, pb: 1 }}>{children}</Box>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OptionsPanel: React.FC = () => {
|
||||||
|
const { options, setOptions } = useOptionsStore()
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: any) => {
|
||||||
|
setOptions({ [field]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
|
⚙️ Options
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Output Section */}
|
||||||
|
<Section title="Output Settings" defaultExpanded>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Format"
|
||||||
|
select
|
||||||
|
value={options.format}
|
||||||
|
onChange={(e) => handleChange('format', e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<MenuItem value="mp3">MP3</MenuItem>
|
||||||
|
<MenuItem value="m4a">M4A</MenuItem>
|
||||||
|
<MenuItem value="mkv">MKV</MenuItem>
|
||||||
|
<MenuItem value="mp4">MP4</MenuItem>
|
||||||
|
<MenuItem value="ogg">OGG</MenuItem>
|
||||||
|
<MenuItem value="opus">OPUS</MenuItem>
|
||||||
|
<MenuItem value="flac">FLAC</MenuItem>
|
||||||
|
<MenuItem value="wav">WAV</MenuItem>
|
||||||
|
<MenuItem value="aac">AAC</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Transcode to"
|
||||||
|
select
|
||||||
|
value={options.transcode_to || ''}
|
||||||
|
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
||||||
|
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
||||||
|
<MenuItem value="aac">AAC</MenuItem>
|
||||||
|
<MenuItem value="libopus">OPUS</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={options.drop_video}
|
||||||
|
onChange={(e) => handleChange('drop_video', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Drop video streams"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={options.drop_subs}
|
||||||
|
onChange={(e) => handleChange('drop_subs', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Drop subtitle streams"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Filename Section */}
|
||||||
|
<Section title="Filename Settings">
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Output template"
|
||||||
|
value={options.output_template}
|
||||||
|
onChange={(e) => handleChange('output_template', e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
helperText="Placeholders: %tn (track name), %an (author), %al (album), %date, %ext, %num"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={options.number_tracks}
|
||||||
|
onChange={(e) => handleChange('number_tracks', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Number tracks (01 - )"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={options.replace_bad_chars}
|
||||||
|
onChange={(e) => handleChange('replace_bad_chars', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Replace bad characters"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Replacement character"
|
||||||
|
value={options.replacement_char}
|
||||||
|
onChange={(e) => handleChange('replacement_char', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
disabled={!options.replace_bad_chars}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Bad characters list"
|
||||||
|
value={options.bad_chars}
|
||||||
|
onChange={(e) => handleChange('bad_chars', e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
disabled={!options.replace_bad_chars}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={options.skip_existing}
|
||||||
|
onChange={(e) => handleChange('skip_existing', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Skip existing files"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Metadata Section */}
|
||||||
|
<Section title="Metadata Settings">
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField
|
||||||
|
label="Album"
|
||||||
|
value={options.album}
|
||||||
|
onChange={(e) => handleChange('album', e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Comment"
|
||||||
|
value={options.comment}
|
||||||
|
onChange={(e) => handleChange('comment', e.target.value)}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={options.no_comment}
|
||||||
|
onChange={(e) => handleChange('no_comment', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="No comment"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Comment stream index"
|
||||||
|
type="number"
|
||||||
|
value={options.comment_stream ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value))
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
disabled={options.no_comment}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={options.merge_comments}
|
||||||
|
onChange={(e) => handleChange('merge_comments', e.target.checked)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Merge all comments"
|
||||||
|
disabled={options.no_comment}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Comment separator"
|
||||||
|
value={options.comment_separator}
|
||||||
|
onChange={(e) => handleChange('comment_separator', e.target.value)}
|
||||||
|
size="small"
|
||||||
|
disabled={!options.merge_comments || options.no_comment}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Section>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
import { Box, Paper, Typography, LinearProgress, Alert, Chip } from '@mui/material'
|
||||||
|
import { useTaskStore } from '../stores/taskStore'
|
||||||
|
|
||||||
|
export const ProgressDisplay: React.FC = () => {
|
||||||
|
const { status, progress, message, error, tracks, logs } = useTaskStore()
|
||||||
|
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (logContainerRef.current) {
|
||||||
|
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [logs])
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'info'
|
||||||
|
case 'processing':
|
||||||
|
return 'warning'
|
||||||
|
case 'done':
|
||||||
|
return 'success'
|
||||||
|
case 'error':
|
||||||
|
return 'error'
|
||||||
|
default:
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusLabel = () => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'Waiting'
|
||||||
|
case 'processing':
|
||||||
|
return 'Processing'
|
||||||
|
case 'done':
|
||||||
|
return 'Complete'
|
||||||
|
case 'error':
|
||||||
|
return 'Error'
|
||||||
|
default:
|
||||||
|
return 'Unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h6">📊 Progress</Typography>
|
||||||
|
<Chip label={getStatusLabel()} color={getStatusColor()} size="small" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mb: 2 }}>
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={progress}
|
||||||
|
color={status === 'error' ? 'error' : status === 'done' ? 'success' : 'primary'}
|
||||||
|
sx={{ height: 10, borderRadius: 5 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
{progress}% – {message}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tracks.length > 0 && status === 'done' && (
|
||||||
|
<Alert severity="success" sx={{ mb: 2 }}>
|
||||||
|
✅ {tracks.length} track(s) extracted successfully!
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box
|
||||||
|
ref={logContainerRef}
|
||||||
|
sx={{
|
||||||
|
maxHeight: 200,
|
||||||
|
overflowY: 'auto',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
p: 2,
|
||||||
|
borderRadius: 1,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
Waiting for progress updates...
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
logs.map((log, index) => (
|
||||||
|
<div key={index} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||||
|
{log}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,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<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
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Paper
|
||||||
|
{...getRootProps()}
|
||||||
|
sx={{
|
||||||
|
p: 3,
|
||||||
|
border: isDragActive ? '2px dashed' : '1px solid',
|
||||||
|
borderColor: isDragActive ? 'primary.main' : 'divider',
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
|
Tracklist
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
|
||||||
|
{rawText ? `${rawText.split('\n').filter((l) => l.trim()).length} tracks` : 'No tracks yet'}
|
||||||
|
{isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
|
{/* Line numbers column */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minWidth: 40,
|
||||||
|
maxWidth: 40,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: 1.7,
|
||||||
|
color: 'text.secondary',
|
||||||
|
textAlign: 'right',
|
||||||
|
userSelect: 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rawText.split('\n').map((_, i) => (
|
||||||
|
<div key={i}>{i + 1}</div>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Editor text area */}
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
fullWidth
|
||||||
|
minRows={10}
|
||||||
|
maxRows={20}
|
||||||
|
value={rawText}
|
||||||
|
onChange={handleTextChange}
|
||||||
|
placeholder={`Enter your tracklist here...\n\nExample:\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
|
||||||
|
variant="outlined"
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-root': {
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: 1.7,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
error={!isValid && errors.length > 0}
|
||||||
|
helperText={
|
||||||
|
!isValid && errors.length > 0
|
||||||
|
? errors.map((e) => `Line ${e.line}: ${e.message}`).join('; ')
|
||||||
|
: 'Drop a .txt file here or paste your tracklist'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isDragActive && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2 }}>
|
||||||
|
Drop your tracklist file (.txt) here
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<Box>
|
||||||
|
<Paper
|
||||||
|
{...getRootProps()}
|
||||||
|
sx={{
|
||||||
|
p: 4,
|
||||||
|
border: '2px dashed',
|
||||||
|
borderColor: isDragActive ? 'primary.main' : 'grey.300',
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: isDragActive ? 'action.hover' : 'background.paper',
|
||||||
|
cursor: isUploading ? 'default' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
|
||||||
|
{file ? (
|
||||||
|
<Box>
|
||||||
|
<InsertDriveFile sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
|
||||||
|
<Typography variant="h6">{fileName}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{formatFileSize(fileSize)}
|
||||||
|
</Typography>
|
||||||
|
{isUploading && (
|
||||||
|
<Box sx={{ mt: 2, width: '100%' }}>
|
||||||
|
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{uploadProgress}% uploaded
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!isUploading && (
|
||||||
|
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
|
||||||
|
✅ Uploaded successfully
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box>
|
||||||
|
<CloudUpload sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
{isDragActive ? 'Drop your audio file here' : 'Drag & drop your audio file here'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||||
|
or click to browse
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ mt: 2, display: 'block' }}>
|
||||||
|
Supported formats: {ALLOWED_EXTENSIONS.join(', ')}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mt: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<WebSocket | null>(null)
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -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<SplitOptions>) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOptionsStore = create<OptionsState>((set) => ({
|
||||||
|
options: { ...DEFAULT_OPTIONS },
|
||||||
|
setOptions: (newOptions) =>
|
||||||
|
set((state) => ({
|
||||||
|
options: { ...state.options, ...newOptions },
|
||||||
|
})),
|
||||||
|
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
|
||||||
|
}))
|
||||||
@@ -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<TaskState>((set) => ({
|
||||||
|
taskId: null,
|
||||||
|
status: null,
|
||||||
|
progress: 0,
|
||||||
|
message: '',
|
||||||
|
error: null,
|
||||||
|
tracks: [],
|
||||||
|
logs: [],
|
||||||
|
isProcessing: false,
|
||||||
|
|
||||||
|
setTaskId: (taskId) => set({ taskId }),
|
||||||
|
setStatus: (status) => set({ status }),
|
||||||
|
setProgress: (progress) => set({ progress }),
|
||||||
|
setMessage: (message) => set({ message }),
|
||||||
|
setError: (error) => set({ error }),
|
||||||
|
setTracks: (tracks) => set({ tracks }),
|
||||||
|
addLog: (log) => set((state) => ({ logs: [...state.logs, log] })),
|
||||||
|
setIsProcessing: (isProcessing) => set({ isProcessing }),
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
taskId: null,
|
||||||
|
status: null,
|
||||||
|
progress: 0,
|
||||||
|
message: '',
|
||||||
|
error: null,
|
||||||
|
tracks: [],
|
||||||
|
logs: [],
|
||||||
|
isProcessing: false,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { TracklistEntry } from '../types'
|
||||||
|
|
||||||
|
interface TracklistState {
|
||||||
|
rawText: string
|
||||||
|
entries: TracklistEntry[]
|
||||||
|
errors: { line: number; message: string }[]
|
||||||
|
isValid: boolean
|
||||||
|
isDragging: boolean
|
||||||
|
|
||||||
|
setRawText: (text: string) => void
|
||||||
|
setEntries: (entries: TracklistEntry[]) => void
|
||||||
|
setErrors: (errors: { line: number; message: string }[]) => void
|
||||||
|
setIsValid: (isValid: boolean) => void
|
||||||
|
setIsDragging: (isDragging: boolean) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTracklistStore = create<TracklistState>((set) => ({
|
||||||
|
rawText: '',
|
||||||
|
entries: [],
|
||||||
|
errors: [],
|
||||||
|
isValid: false,
|
||||||
|
isDragging: false,
|
||||||
|
|
||||||
|
setRawText: (rawText) => set({ rawText }),
|
||||||
|
setEntries: (entries) => set({ entries }),
|
||||||
|
setErrors: (errors) => set({ errors }),
|
||||||
|
setIsValid: (isValid) => set({ isValid }),
|
||||||
|
setIsDragging: (isDragging) => set({ isDragging }),
|
||||||
|
reset: () =>
|
||||||
|
set({
|
||||||
|
rawText: '',
|
||||||
|
entries: [],
|
||||||
|
errors: [],
|
||||||
|
isValid: false,
|
||||||
|
isDragging: false,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface UIState {
|
||||||
|
theme: 'light' | 'dark'
|
||||||
|
isSidebarOpen: boolean
|
||||||
|
wsConnected: boolean
|
||||||
|
|
||||||
|
toggleTheme: () => void
|
||||||
|
setTheme: (theme: 'light' | 'dark') => void
|
||||||
|
toggleSidebar: () => void
|
||||||
|
setSidebarOpen: (isOpen: boolean) => void
|
||||||
|
setWsConnected: (connected: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUIStore = create<UIState>((set) => ({
|
||||||
|
theme: 'light',
|
||||||
|
isSidebarOpen: false,
|
||||||
|
wsConnected: false,
|
||||||
|
|
||||||
|
toggleTheme: () =>
|
||||||
|
set((state) => ({
|
||||||
|
theme: state.theme === 'light' ? 'dark' : 'light',
|
||||||
|
})),
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
toggleSidebar: () =>
|
||||||
|
set((state) => ({
|
||||||
|
isSidebarOpen: !state.isSidebarOpen,
|
||||||
|
})),
|
||||||
|
setSidebarOpen: (isSidebarOpen) => set({ isSidebarOpen }),
|
||||||
|
setWsConnected: (wsConnected) => set({ wsConnected }),
|
||||||
|
}))
|
||||||
@@ -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<UploadState>((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,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user