Frontend and backend work together fine, complete workflow is implemented
This commit is contained in:
Generated
+4239
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } }),
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user