Frontend and backend work together fine, complete workflow is implemented

This commit is contained in:
2026-08-01 17:04:03 +05:00
parent e87d8089bf
commit cd3ce0337b
13 changed files with 4499 additions and 86 deletions
+17 -1
View File
@@ -62,6 +62,20 @@ export const OptionsPanel: React.FC = () => {
Options
</Typography>
{/* Tracklist Section */}
<Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Tracklist Format"
value={options.tracklist_format}
onChange={(e) => handleChange('tracklist_format', e.target.value)}
fullWidth
size="small"
helperText="Placeholders: %ts (timestamp), %tn (track name), %an (author), %al (album), %date, %ext"
/>
</Box>
</Section>
{/* Output Section */}
<Section title="Output Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -232,4 +246,6 @@ export const OptionsPanel: React.FC = () => {
</Section>
</Paper>
)
}
}
+19 -20
View File
@@ -2,29 +2,28 @@ import React, { useCallback, useEffect, useState } from 'react'
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore'
import { validateTracklist, parseTracklist } from '../utils/validators'
import { useOptionsStore } from '../stores/optionsStore'
import { parseAndValidateTracklist } from '../utils/validators'
import { TracklistEntry } from '../types'
export const TracklistEditor: React.FC = () => {
const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
const { options } = useOptionsStore()
const [isDragging, setIsDragging] = useState(false)
const validate = (text: string) => {
const result = parseAndValidateTracklist(text, options.tracklist_format)
setEntries(result.entries)
setErrors(result.errors)
setIsValid(result.isValid)
}
const handleTextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = event.target.value
setRawText(text)
validate(text)
}
const validate = (text: string) => {
const lines = text.split('\n').filter((line) => line.trim() !== '')
const parsed = parseTracklist(lines)
setEntries(parsed)
const validation = validateTracklist(parsed)
setErrors(validation.errors)
setIsValid(validation.isValid)
}
const onDrop = useCallback(
(acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return
@@ -50,17 +49,14 @@ export const TracklistEditor: React.FC = () => {
multiple: false,
})
// Initial validation on mount
// Re-validate when tracklist format changes
useEffect(() => {
if (rawText) {
validate(rawText)
}
}, [])
}, [options.tracklist_format])
const getLineClassName = (lineIndex: number): string => {
const hasError = errors.some((e) => e.line === lineIndex + 1)
return hasError ? 'error-line' : ''
}
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
return (
<Paper
@@ -79,9 +75,12 @@ export const TracklistEditor: React.FC = () => {
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Tracklist
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
{rawText ? `${rawText.split('\n').filter((l) => l.trim()).length} tracks` : 'No tracks yet'}
{lineCount} track(s)
{isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
Format: {options.tracklist_format}
</Typography>
</Typography>
<Box sx={{ display: 'flex', gap: 2 }}>
@@ -112,7 +111,7 @@ export const TracklistEditor: React.FC = () => {
maxRows={20}
value={rawText}
onChange={handleTextChange}
placeholder={`Enter your tracklist here...\n\nExample:\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
placeholder={`Enter your tracklist here...\n\nExample (format: ${options.tracklist_format}):\n00:00 Intro\n01:30 Song One - Artist A\n04:20-06:45 Another Song - Artist B`}
variant="outlined"
sx={{
'& .MuiInputBase-root': {
@@ -137,4 +136,4 @@ export const TracklistEditor: React.FC = () => {
)}
</Paper>
)
}
}
+33 -14
View File
@@ -7,7 +7,16 @@ export const useWebSocket = (taskId: string | null) => {
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttempts = useRef(0)
const { setStatus, setProgress, setMessage, setError, setTracks, addLog } = useTaskStore()
const {
setStatus,
setProgress,
setMessage,
setError,
setTracks,
addLog,
setIsProcessing,
status,
} = useTaskStore()
const { setWsConnected } = useUIStore()
useEffect(() => {
@@ -39,15 +48,19 @@ export const useWebSocket = (taskId: string | null) => {
const data = JSON.parse(event.data)
if (data.type === 'status') {
const status = data.data
setStatus(status.status)
setProgress(status.progress)
setMessage(status.message)
if (status.error) {
setError(status.error)
const statusData = data.data
setStatus(statusData.status)
setProgress(statusData.progress)
setMessage(statusData.message)
if (statusData.error) {
setError(statusData.error)
}
if (status.tracks) {
setTracks(status.tracks)
if (statusData.tracks) {
setTracks(statusData.tracks)
}
// If status is done or error, stop processing
if (statusData.status === 'done' || statusData.status === 'error') {
setIsProcessing(false)
}
} else if (data.type === 'progress') {
const progressData = data.data
@@ -56,9 +69,15 @@ export const useWebSocket = (taskId: string | null) => {
setMessage(progressData.message)
if (progressData.status === 'done') {
addLog('✅ Split complete!')
setIsProcessing(false)
// If tracks are included, update them
if (progressData.tracks) {
setTracks(progressData.tracks)
}
} else if (progressData.status === 'error') {
setError(progressData.message)
addLog(`❌ Error: ${progressData.message}`)
setIsProcessing(false)
} else {
addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
}
@@ -72,8 +91,8 @@ export const useWebSocket = (taskId: string | null) => {
console.log(`WebSocket disconnected for task ${taskId}`)
setWsConnected(false)
// Try to reconnect if the task is still processing
// We'll check status via polling if needed
// If task is still in processing state, reconnect
// We'll check via polling if needed
if (reconnectAttempts.current < 5) {
reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttempts.current += 1
@@ -84,7 +103,7 @@ export const useWebSocket = (taskId: string | null) => {
ws.onerror = (error) => {
console.error('WebSocket error:', error)
// The onclose will handle reconnection
// onclose will handle reconnection
}
wsRef.current = ws
@@ -103,7 +122,7 @@ export const useWebSocket = (taskId: string | null) => {
}
setWsConnected(false)
}
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected])
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing])
return wsRef.current
}
}
+2 -1
View File
@@ -18,6 +18,7 @@ const DEFAULT_OPTIONS: SplitOptions = {
comment_stream: null,
merge_comments: false,
comment_separator: '; ',
tracklist_format: '%ts %tn - %an', // NEW
}
interface OptionsState {
@@ -33,4 +34,4 @@ export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...state.options, ...newOptions },
})),
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
}))
}))
+2 -1
View File
@@ -38,6 +38,7 @@ export interface SplitOptions {
comment_stream: number | null
merge_comments: boolean
comment_separator: string
tracklist_format: string
}
export interface UploadResponse {
@@ -49,4 +50,4 @@ export interface UploadResponse {
export interface SplitResponse {
task_id: string
status: string
}
}
+119
View File
@@ -0,0 +1,119 @@
// web/frontend/src/utils/parser.ts
import { TracklistEntry } from '../types'
type Token = { type: 'literal'; value: string } | { type: 'placeholder'; value: string }
export function parseFormat(formatStr: string): Token[] {
const validPlaceholders = new Set(['ts', 'tn', 'an', 'al', 'date', 'ext'])
const tokens: Token[] = []
let i = 0
while (i < formatStr.length) {
const ch = formatStr[i]
if (ch === '%') {
if (i + 1 < formatStr.length && formatStr[i + 1] === '%') {
tokens.push({ type: 'literal', value: '%' })
i += 2
continue
}
// match %letters
const match = formatStr.substring(i).match(/^%([a-zA-Z]+)/)
if (!match) {
throw new Error(`Invalid placeholder at position ${i}: '${formatStr.substring(i)}'`)
}
const placeholder = match[1]
if (!validPlaceholders.has(placeholder)) {
throw new Error(`Unknown placeholder '%${placeholder}'. Allowed: ${Array.from(validPlaceholders).join(', ')}`)
}
tokens.push({ type: 'placeholder', value: placeholder })
i += match[0].length
} else {
let j = i
while (j < formatStr.length && formatStr[j] !== '%') {
j++
}
tokens.push({ type: 'literal', value: formatStr.substring(i, j) })
i = j
}
}
return tokens
}
export function parseLine(line: string, tokens: Token[]): Record<string, string | null> {
line = line.trim()
if (!line) {
throw new Error('Empty line')
}
const result: Record<string, string | null> = {}
let pos = 0
for (let idx = 0; idx < tokens.length; idx++) {
const token = tokens[idx]
if (token.type === 'literal') {
const literal = token.value
if (!line.startsWith(literal, pos)) {
throw new Error(`Expected literal '${literal}' at position ${pos}, got '${line.substring(pos)}'`)
}
pos += literal.length
} else {
// placeholder
const placeholder = token.value
// If this is the last token, capture the rest
if (idx === tokens.length - 1) {
const value = line.substring(pos).trim()
result[placeholder] = value || null
pos = line.length
} else {
// Find the next literal to use as delimiter
let nextLiteral: string | null = null
for (let j = idx + 1; j < tokens.length; j++) {
if (tokens[j].type === 'literal') {
nextLiteral = tokens[j].value
break
}
}
if (nextLiteral === null) {
const value = line.substring(pos).trim()
result[placeholder] = value || null
pos = line.length
} else {
const nextPos = line.indexOf(nextLiteral, pos)
if (nextPos === -1) {
throw new Error(`Could not find literal '${nextLiteral}' after placeholder '${placeholder}'`)
}
const value = line.substring(pos, nextPos).trim()
result[placeholder] = value || null
pos = nextPos
}
}
}
}
return result
}
export function parseTracklistWithFormat(text: string, format: string): TracklistEntry[] {
const tokens = parseFormat(format)
const lines = text.split('\n').filter(line => line.trim() !== '')
const entries: TracklistEntry[] = []
for (const line of lines) {
try {
const parsed = parseLine(line, tokens)
const entry: TracklistEntry = {
ts: parsed.ts || '',
tn: parsed.tn || '',
an: parsed.an || '',
al: parsed.al || '',
date: parsed.date || '',
ext: parsed.ext || '',
}
entries.push(entry)
} catch (error) {
// We'll handle errors in the validator; just skip or mark as invalid
// For now, we'll push an empty entry with an error flag
entries.push({ ts: '', tn: line, an: '' })
}
}
return entries
}
+35 -29
View File
@@ -1,32 +1,7 @@
import { TracklistEntry } from '../types'
// web/frontend/src/utils/validators.ts
export const parseTracklist = (lines: string[]): TracklistEntry[] => {
return lines.map((line) => {
const trimmed = line.trim()
// Try to parse as "%ts %tn - %an"
const match = trimmed.match(/^(\d{1,2}:\d{2}(:\d{2})?)\s+(.+?)\s*-\s*(.+)$/)
if (match) {
return {
ts: match[1],
tn: match[3].trim(),
an: match[4].trim(),
}
}
// Try to parse as "%ts %tn"
const simpleMatch = trimmed.match(/^(\d{1,2}:\d{2}(:\d{2})?)\s+(.+)$/)
if (simpleMatch) {
return {
ts: simpleMatch[1],
tn: simpleMatch[3].trim(),
}
}
// Fallback: just use the line as is (will be flagged as error)
return {
ts: '',
tn: trimmed,
}
})
}
import { TracklistEntry } from '../types'
import { parseTracklistWithFormat } from './parser'
export const validateTracklist = (
entries: TracklistEntry[]
@@ -53,4 +28,35 @@ export const validateTracklist = (
isValid: errors.length === 0,
errors,
}
}
}
// New function that parses and validates using the format
export function parseAndValidateTracklist(text: string, format: string): {
entries: TracklistEntry[]
errors: { line: number; message: string }[]
isValid: boolean
} {
// We need to handle parsing errors gracefully.
// parseTracklistWithFormat will throw on some errors, but we can catch and mark as invalid.
try {
const entries = parseTracklistWithFormat(text, format)
const validation = validateTracklist(entries)
return {
entries,
errors: validation.errors,
isValid: validation.isValid,
}
} catch (error) {
// If parsing fails (e.g., invalid format), treat all lines as errors
const lines = text.split('\n').filter(line => line.trim() !== '')
const errors = lines.map((_, index) => ({
line: index + 1,
message: error instanceof Error ? error.message : 'Parse error',
}))
return {
entries: [],
errors,
isValid: false,
}
}
}