Web interface merge #1
+3
-1
@@ -3,4 +3,6 @@ build/
|
|||||||
dist/
|
dist/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
*.pyc
|
*.pyc
|
||||||
test_data/
|
test_data/
|
||||||
|
venv/
|
||||||
|
web/frontend/node_modules
|
||||||
|
|||||||
+7
-1
@@ -1,11 +1,13 @@
|
|||||||
"""FastAPI application entry point."""
|
"""FastAPI application entry point."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from backend.api import upload, split, status, download
|
from backend.api import upload, split, status, download
|
||||||
from backend.api.websocket import router as websocket_router
|
from backend.api.websocket import router as websocket_router
|
||||||
|
from backend.services import progress_publisher # Import the module
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Audio Splitter Web API",
|
title="Audio Splitter Web API",
|
||||||
@@ -22,6 +24,10 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store the main event loop in the progress_publisher module
|
||||||
|
# This avoids a circular import
|
||||||
|
progress_publisher.MAIN_LOOP = asyncio.get_running_loop()
|
||||||
|
|
||||||
# Include routers
|
# Include routers
|
||||||
app.include_router(upload.router)
|
app.include_router(upload.router)
|
||||||
app.include_router(split.router)
|
app.include_router(split.router)
|
||||||
@@ -37,4 +43,4 @@ async def root():
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "healthy"}
|
return {"status": "healthy"}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
"""WebSocket progress publisher – decouples task manager from WebSocket."""
|
"""WebSocket progress publisher – decouples task manager from WebSocket."""
|
||||||
|
|
||||||
import json
|
import asyncio
|
||||||
from typing import Dict, Set
|
from typing import Dict, Set
|
||||||
|
|
||||||
from fastapi import WebSocket
|
from fastapi import WebSocket
|
||||||
|
|
||||||
# Active WebSocket connections
|
# This will be set by main.py when the app starts
|
||||||
|
MAIN_LOOP = None
|
||||||
|
|
||||||
active_connections: Dict[str, Set[WebSocket]] = {}
|
active_connections: Dict[str, Set[WebSocket]] = {}
|
||||||
|
|
||||||
|
|
||||||
def publish_progress(task_id: str, progress: int, message: str, status: str = "processing"):
|
def publish_progress(task_id: str, progress: int, message: str, status: str = "processing"):
|
||||||
|
"""
|
||||||
|
Publish progress update to all connected WebSocket clients for a task.
|
||||||
|
"""
|
||||||
if task_id not in active_connections:
|
if task_id not in active_connections:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -26,10 +31,14 @@ def publish_progress(task_id: str, progress: int, message: str, status: str = "p
|
|||||||
to_remove = set()
|
to_remove = set()
|
||||||
for websocket in active_connections.get(task_id, set()):
|
for websocket in active_connections.get(task_id, set()):
|
||||||
try:
|
try:
|
||||||
websocket.send_json(data)
|
# Use the stored main loop, or fallback to getting the current loop
|
||||||
|
loop = MAIN_LOOP or asyncio.get_running_loop()
|
||||||
|
asyncio.run_coroutine_threadsafe(websocket.send_json(data), loop)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# Client disconnected or other error
|
||||||
to_remove.add(websocket)
|
to_remove.add(websocket)
|
||||||
|
|
||||||
|
# Clean up disconnected clients
|
||||||
for websocket in to_remove:
|
for websocket in to_remove:
|
||||||
active_connections[task_id].discard(websocket)
|
active_connections[task_id].discard(websocket)
|
||||||
if task_id in active_connections and not active_connections[task_id]:
|
if task_id in active_connections and not active_connections[task_id]:
|
||||||
@@ -37,13 +46,15 @@ def publish_progress(task_id: str, progress: int, message: str, status: str = "p
|
|||||||
|
|
||||||
|
|
||||||
def register_connection(task_id: str, websocket: WebSocket):
|
def register_connection(task_id: str, websocket: WebSocket):
|
||||||
|
"""Register a WebSocket connection for a task."""
|
||||||
if task_id not in active_connections:
|
if task_id not in active_connections:
|
||||||
active_connections[task_id] = set()
|
active_connections[task_id] = set()
|
||||||
active_connections[task_id].add(websocket)
|
active_connections[task_id].add(websocket)
|
||||||
|
|
||||||
|
|
||||||
def unregister_connection(task_id: str, websocket: WebSocket):
|
def unregister_connection(task_id: str, websocket: WebSocket):
|
||||||
|
"""Unregister a WebSocket connection for a task."""
|
||||||
if task_id in active_connections:
|
if task_id in active_connections:
|
||||||
active_connections[task_id].discard(websocket)
|
active_connections[task_id].discard(websocket)
|
||||||
if not active_connections[task_id]:
|
if not active_connections[task_id]:
|
||||||
del active_connections[task_id]
|
del active_connections[task_id]
|
||||||
|
|||||||
Binary file not shown.
@@ -5,6 +5,7 @@ import sys
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
import time
|
||||||
|
|
||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from backend.services.task_manager import task_manager
|
from backend.services.task_manager import task_manager
|
||||||
@@ -65,27 +66,17 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
|||||||
|
|
||||||
from audio_splitter.core import split_audio
|
from audio_splitter.core import split_audio
|
||||||
|
|
||||||
# We need to track progress from the core.
|
|
||||||
# Since the core doesn't have a progress callback, we'll update progress
|
|
||||||
# based on track list size (approximate).
|
|
||||||
total_tracks = len(tracks)
|
|
||||||
progress_base = 10 # Starting progress after init
|
|
||||||
|
|
||||||
task_manager.update_task_with_progress(
|
task_manager.update_task_with_progress(
|
||||||
task_id, progress=progress_base, message="Starting split..."
|
task_id, progress=10, message="Starting split..."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Run the split
|
# Run the split
|
||||||
# The core prints progress to stdout, but we can't easily capture it.
|
|
||||||
# We'll update progress based on track count.
|
|
||||||
# For now, we'll report progress after the split completes.
|
|
||||||
# A more advanced implementation would capture stdout or add a callback.
|
|
||||||
|
|
||||||
split_audio(str(input_path), str(output_dir), tracks, args)
|
split_audio(str(input_path), str(output_dir), tracks, args)
|
||||||
|
|
||||||
# After split completes, get the output files
|
# Get output files
|
||||||
output_files = FileManager.get_output_files(task_id)
|
output_files = FileManager.get_output_files(task_id)
|
||||||
|
|
||||||
|
# Final status update
|
||||||
task_manager.update_task_with_progress(
|
task_manager.update_task_with_progress(
|
||||||
task_id,
|
task_id,
|
||||||
progress=100,
|
progress=100,
|
||||||
@@ -99,6 +90,9 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
|||||||
tracks=output_files
|
tracks=output_files
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Give WebSocket time to send the final message
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
task_manager.update_task_with_progress(
|
task_manager.update_task_with_progress(
|
||||||
task_id,
|
task_id,
|
||||||
@@ -106,4 +100,4 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[
|
|||||||
message="Split failed",
|
message="Split failed",
|
||||||
status=TaskStatus.ERROR,
|
status=TaskStatus.ERROR,
|
||||||
error=str(e)
|
error=str(e)
|
||||||
)
|
)
|
||||||
|
|||||||
Generated
+4239
File diff suppressed because it is too large
Load Diff
@@ -62,6 +62,20 @@ export const OptionsPanel: React.FC = () => {
|
|||||||
⚙️ Options
|
⚙️ Options
|
||||||
</Typography>
|
</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 */}
|
{/* Output Section */}
|
||||||
<Section title="Output Settings" defaultExpanded>
|
<Section title="Output Settings" defaultExpanded>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
@@ -232,4 +246,6 @@ export const OptionsPanel: React.FC = () => {
|
|||||||
</Section>
|
</Section>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,28 @@ import React, { useCallback, useEffect, useState } from 'react'
|
|||||||
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
|
import { Box, Paper, TextField, Typography, Alert } from '@mui/material'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { useTracklistStore } from '../stores/tracklistStore'
|
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'
|
import { TracklistEntry } from '../types'
|
||||||
|
|
||||||
export const TracklistEditor: React.FC = () => {
|
export const TracklistEditor: React.FC = () => {
|
||||||
const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
|
const { rawText, errors, isValid, setRawText, setEntries, setErrors, setIsValid } = useTracklistStore()
|
||||||
|
const { options } = useOptionsStore()
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
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 handleTextChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
const text = event.target.value
|
const text = event.target.value
|
||||||
setRawText(text)
|
setRawText(text)
|
||||||
validate(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(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[]) => {
|
(acceptedFiles: File[]) => {
|
||||||
if (acceptedFiles.length === 0) return
|
if (acceptedFiles.length === 0) return
|
||||||
@@ -50,17 +49,14 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
multiple: false,
|
multiple: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Initial validation on mount
|
// Re-validate when tracklist format changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (rawText) {
|
if (rawText) {
|
||||||
validate(rawText)
|
validate(rawText)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [options.tracklist_format])
|
||||||
|
|
||||||
const getLineClassName = (lineIndex: number): string => {
|
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
|
||||||
const hasError = errors.some((e) => e.line === lineIndex + 1)
|
|
||||||
return hasError ? 'error-line' : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -79,9 +75,12 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
<Typography variant="subtitle1" sx={{ mb: 2 }}>
|
||||||
Tracklist
|
Tracklist
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
|
<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)` : ''}
|
{isValid ? ' ✅' : errors.length > 0 ? ` ⚠️ ${errors.length} error(s)` : ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ ml: 2 }}>
|
||||||
|
Format: {options.tracklist_format}
|
||||||
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
@@ -112,7 +111,7 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
maxRows={20}
|
maxRows={20}
|
||||||
value={rawText}
|
value={rawText}
|
||||||
onChange={handleTextChange}
|
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"
|
variant="outlined"
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiInputBase-root': {
|
'& .MuiInputBase-root': {
|
||||||
@@ -137,4 +136,4 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,16 @@ export const useWebSocket = (taskId: string | null) => {
|
|||||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
const reconnectAttempts = useRef(0)
|
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()
|
const { setWsConnected } = useUIStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,15 +48,19 @@ export const useWebSocket = (taskId: string | null) => {
|
|||||||
const data = JSON.parse(event.data)
|
const data = JSON.parse(event.data)
|
||||||
|
|
||||||
if (data.type === 'status') {
|
if (data.type === 'status') {
|
||||||
const status = data.data
|
const statusData = data.data
|
||||||
setStatus(status.status)
|
setStatus(statusData.status)
|
||||||
setProgress(status.progress)
|
setProgress(statusData.progress)
|
||||||
setMessage(status.message)
|
setMessage(statusData.message)
|
||||||
if (status.error) {
|
if (statusData.error) {
|
||||||
setError(status.error)
|
setError(statusData.error)
|
||||||
}
|
}
|
||||||
if (status.tracks) {
|
if (statusData.tracks) {
|
||||||
setTracks(status.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') {
|
} else if (data.type === 'progress') {
|
||||||
const progressData = data.data
|
const progressData = data.data
|
||||||
@@ -56,9 +69,15 @@ export const useWebSocket = (taskId: string | null) => {
|
|||||||
setMessage(progressData.message)
|
setMessage(progressData.message)
|
||||||
if (progressData.status === 'done') {
|
if (progressData.status === 'done') {
|
||||||
addLog('✅ Split complete!')
|
addLog('✅ Split complete!')
|
||||||
|
setIsProcessing(false)
|
||||||
|
// If tracks are included, update them
|
||||||
|
if (progressData.tracks) {
|
||||||
|
setTracks(progressData.tracks)
|
||||||
|
}
|
||||||
} else if (progressData.status === 'error') {
|
} else if (progressData.status === 'error') {
|
||||||
setError(progressData.message)
|
setError(progressData.message)
|
||||||
addLog(`❌ Error: ${progressData.message}`)
|
addLog(`❌ Error: ${progressData.message}`)
|
||||||
|
setIsProcessing(false)
|
||||||
} else {
|
} else {
|
||||||
addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
|
addLog(`🔄 ${progressData.message} (${progressData.progress}%)`)
|
||||||
}
|
}
|
||||||
@@ -72,8 +91,8 @@ export const useWebSocket = (taskId: string | null) => {
|
|||||||
console.log(`WebSocket disconnected for task ${taskId}`)
|
console.log(`WebSocket disconnected for task ${taskId}`)
|
||||||
setWsConnected(false)
|
setWsConnected(false)
|
||||||
|
|
||||||
// Try to reconnect if the task is still processing
|
// If task is still in processing state, reconnect
|
||||||
// We'll check status via polling if needed
|
// We'll check via polling if needed
|
||||||
if (reconnectAttempts.current < 5) {
|
if (reconnectAttempts.current < 5) {
|
||||||
reconnectTimeoutRef.current = setTimeout(() => {
|
reconnectTimeoutRef.current = setTimeout(() => {
|
||||||
reconnectAttempts.current += 1
|
reconnectAttempts.current += 1
|
||||||
@@ -84,7 +103,7 @@ export const useWebSocket = (taskId: string | null) => {
|
|||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
console.error('WebSocket error:', error)
|
console.error('WebSocket error:', error)
|
||||||
// The onclose will handle reconnection
|
// onclose will handle reconnection
|
||||||
}
|
}
|
||||||
|
|
||||||
wsRef.current = ws
|
wsRef.current = ws
|
||||||
@@ -103,7 +122,7 @@ export const useWebSocket = (taskId: string | null) => {
|
|||||||
}
|
}
|
||||||
setWsConnected(false)
|
setWsConnected(false)
|
||||||
}
|
}
|
||||||
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected])
|
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing])
|
||||||
|
|
||||||
return wsRef.current
|
return wsRef.current
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const DEFAULT_OPTIONS: SplitOptions = {
|
|||||||
comment_stream: null,
|
comment_stream: null,
|
||||||
merge_comments: false,
|
merge_comments: false,
|
||||||
comment_separator: '; ',
|
comment_separator: '; ',
|
||||||
|
tracklist_format: '%ts %tn - %an', // NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OptionsState {
|
interface OptionsState {
|
||||||
@@ -33,4 +34,4 @@ export const useOptionsStore = create<OptionsState>((set) => ({
|
|||||||
options: { ...state.options, ...newOptions },
|
options: { ...state.options, ...newOptions },
|
||||||
})),
|
})),
|
||||||
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
|
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface SplitOptions {
|
|||||||
comment_stream: number | null
|
comment_stream: number | null
|
||||||
merge_comments: boolean
|
merge_comments: boolean
|
||||||
comment_separator: string
|
comment_separator: string
|
||||||
|
tracklist_format: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadResponse {
|
export interface UploadResponse {
|
||||||
@@ -49,4 +50,4 @@ export interface UploadResponse {
|
|||||||
export interface SplitResponse {
|
export interface SplitResponse {
|
||||||
task_id: string
|
task_id: string
|
||||||
status: 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[] => {
|
import { TracklistEntry } from '../types'
|
||||||
return lines.map((line) => {
|
import { parseTracklistWithFormat } from './parser'
|
||||||
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 = (
|
export const validateTracklist = (
|
||||||
entries: TracklistEntry[]
|
entries: TracklistEntry[]
|
||||||
@@ -53,4 +28,35 @@ export const validateTracklist = (
|
|||||||
isValid: errors.length === 0,
|
isValid: errors.length === 0,
|
||||||
errors,
|
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