Download section didn't appear. Resolved

This commit is contained in:
2026-08-03 10:46:01 +05:00
parent ea49920a12
commit e23b0f44ce
16 changed files with 157 additions and 72 deletions
+1 -1
View File
@@ -54,4 +54,4 @@ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
# Default command (shows help if no arguments provided) # Default command (shows help if no arguments provided)
CMD ["--help"] CMD ["--help"]
+4 -4
View File
@@ -1,12 +1,12 @@
services: services:
backend: backend:
build: build:
context: . # Project root (contains audio_splitter/) context: .
dockerfile: web/backend/Dockerfile dockerfile: web/backend/Dockerfile
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- /tmp/audio_splitter_web:/tmp/audio_splitter_web - /tmp/audio_splitter_web:/tmp/audio_splitter_web # Bind mount
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- DEBUG=1 - DEBUG=1
@@ -15,9 +15,9 @@ services:
frontend: frontend:
build: build:
context: ./web/frontend context: ./web/frontend
dockerfile: Dockerfile.dev dockerfile: Dockerfile
ports: ports:
- "5173:5173" - "5173:80"
volumes: volumes:
- ./web/frontend:/app - ./web/frontend:/app
- /app/node_modules - /app/node_modules
+12 -21
View File
@@ -1,10 +1,11 @@
FROM python:3.13-slim FROM python:3.13-slim
# Install FFmpeg and system dependencies # Install FFmpeg, system dependencies, and gosu from APT
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
ffmpeg \ ffmpeg \
ca-certificates \ ca-certificates \
gosu \
&& \ && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
@@ -16,39 +17,29 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# ------------------------------------------------------------ # Copy requirements and install Python dependencies
# Copy and install Python dependencies
# ------------------------------------------------------------
COPY web/backend/requirements-web.txt . COPY web/backend/requirements-web.txt .
RUN pip install --no-cache-dir -r requirements-web.txt RUN pip install --no-cache-dir -r requirements-web.txt
# ------------------------------------------------------------ # Copy backend code
# Copy the backend application code
# ------------------------------------------------------------
COPY web/backend /app/backend COPY web/backend /app/backend
# ------------------------------------------------------------ # Copy and install audio_splitter package
# Copy and install the audio_splitter package
# ------------------------------------------------------------
COPY audio_splitter /app/audio_splitter COPY audio_splitter /app/audio_splitter
COPY setup.py pyproject.toml README.md /app/ COPY setup.py pyproject.toml README.md /app/
# Install audio_splitter as a package
RUN pip install --no-cache-dir /app RUN pip install --no-cache-dir /app
# ------------------------------------------------------------
# Create a non-root user # Create a non-root user
# ------------------------------------------------------------
RUN addgroup --system --gid 1000 appgroup && \ RUN addgroup --system --gid 1000 appgroup && \
adduser --system --uid 1000 --ingroup appgroup appuser && \ adduser --system --uid 1000 --ingroup appgroup appuser && \
chown -R appuser:appgroup /app chown -R appuser:appgroup /app
USER appuser # Copy entrypoint script
COPY web/backend/docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
# ------------------------------------------------------------ # Set entrypoint
# Expose port and start ENTRYPOINT ["/docker-entrypoint.sh"]
# ------------------------------------------------------------
# Expose port
EXPOSE 8000 EXPOSE 8000
# PYTHONPATH is already /app by default because we set WORKDIR /app
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
set -e
# Detect if we are running as root (default)
if [ "$(id -u)" = "0" ]; then
# Ensure the temp directory exists and set ownership
if [ -d "/tmp/audio_splitter_web" ]; then
echo "Setting ownership of /tmp/audio_splitter_web to appuser:appgroup"
chown -R appuser:appgroup /tmp/audio_splitter_web
else
echo "Creating /tmp/audio_splitter_web and setting ownership"
mkdir -p /tmp/audio_splitter_web
chown -R appuser:appgroup /tmp/audio_splitter_web
fi
# Drop privileges and run uvicorn using gosu
exec gosu appuser uvicorn backend.main:app --host 0.0.0.0 --port 8000
else
# If not root, just run directly
exec uvicorn backend.main:app --host 0.0.0.0 --port 8000
fi
+1 -1
View File
@@ -18,7 +18,7 @@ FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration # Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
# Expose the port # Expose the port
EXPOSE 80 EXPOSE 80
@@ -2,6 +2,7 @@ server {
listen 80; listen 80;
server_name _; server_name _;
client_max_body_size 500M;
# Root directory for static files # Root directory for static files
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
+15 -7
View File
@@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material' import { ThemeProvider, createTheme, CssBaseline } from '@mui/material'
import { Box, Grid, Button, Alert, CircularProgress } from '@mui/material' import { Box, Grid, Button, CircularProgress } from '@mui/material'
import { PlayArrow } from '@mui/icons-material' import { PlayArrow } from '@mui/icons-material'
import { Layout } from './components/Layout' import { Layout } from './components/Layout'
import { UploadZone } from './components/UploadZone' import { UploadZone } from './components/UploadZone'
@@ -18,11 +18,17 @@ import { startSplit } from './api/client'
const App: React.FC = () => { const App: React.FC = () => {
const { theme } = useUIStore() const { theme } = useUIStore()
const { taskId, file } = useUploadStore() const { taskId } = useUploadStore()
const { entries, isValid } = useTracklistStore() const { entries, isValid } = useTracklistStore()
const { options } = useOptionsStore() const { options } = useOptionsStore()
const { isProcessing, status, setStatus, setProgress, setMessage, setError, setIsProcessing, addLog, reset } = const {
useTaskStore() isProcessing,
setTaskId, // <-- Add this
setError,
setIsProcessing,
addLog,
reset,
} = useTaskStore()
// Connect WebSocket when taskId is available and processing // Connect WebSocket when taskId is available and processing
useWebSocket(taskId && isProcessing ? taskId : null) useWebSocket(taskId && isProcessing ? taskId : null)
@@ -44,6 +50,8 @@ const App: React.FC = () => {
} }
try { try {
// Set taskId in taskStore so DownloadSection can use it
setTaskId(taskId)
setIsProcessing(true) setIsProcessing(true)
addLog('🚀 Starting split...') addLog('🚀 Starting split...')
@@ -106,8 +114,8 @@ const App: React.FC = () => {
</Button> </Button>
</Box> </Box>
{status && <ProgressDisplay />} <ProgressDisplay />
{status === 'done' && <DownloadSection />} <DownloadSection />
</Grid> </Grid>
</Grid> </Grid>
</Layout> </Layout>
@@ -115,4 +123,4 @@ const App: React.FC = () => {
) )
} }
export default App export default App
+2 -1
View File
@@ -1,4 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', baseURL: '/api',
@@ -44,4 +45,4 @@ export const getDownloadUrl = (task_id: string): string => {
export const getDownloadZipUrl = (task_id: string): string => { export const getDownloadZipUrl = (task_id: string): string => {
return `/api/download/${task_id}/splits.zip` return `/api/download/${task_id}/splits.zip`
} }
@@ -1,19 +1,25 @@
import React from 'react' import React from 'react'
import { Box, Paper, Typography, Button, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material' import { Paper, Typography, Button, List, ListItem, ListItemText, IconButton, Divider } from '@mui/material'
import { Download, FileDownload, FolderZip } from '@mui/icons-material' import { Download, FolderZip } from '@mui/icons-material'
import { useTaskStore } from '../stores/taskStore' import { useTaskStore } from '../stores/taskStore'
import { getDownloadUrl, getDownloadZipUrl } from '../api/client' import { getDownloadZipUrl } from '../api/client'
import { formatFileSize } from '../utils/formatters' import { formatFileSize } from '../utils/formatters'
export const DownloadSection: React.FC = () => { export const DownloadSection: React.FC = () => {
const { taskId, tracks, status } = useTaskStore() const { taskId, tracks, status } = useTaskStore()
if (status !== 'done' || tracks.length === 0) { console.log('[DownloadSection] Rendering:', { status, tracks, taskId })
// Check if we should show the download section
if (status !== 'done' || !tracks || tracks.length === 0 || !taskId) {
return null return null
} }
// If we get here, we have tracks
console.log('[DownloadSection] Showing tracks:', tracks)
const handleDownloadZip = () => { const handleDownloadZip = () => {
const url = getDownloadZipUrl(taskId!) const url = getDownloadZipUrl(taskId)
window.open(url, '_blank') window.open(url, '_blank')
} }
@@ -23,7 +29,7 @@ export const DownloadSection: React.FC = () => {
} }
return ( return (
<Paper sx={{ p: 3 }}> <Paper sx={{ p: 3, mt: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
📥 Download Results 📥 Download Results
</Typography> </Typography>
@@ -64,4 +70,4 @@ export const DownloadSection: React.FC = () => {
</List> </List>
</Paper> </Paper>
) )
} }
+2 -1
View File
@@ -4,7 +4,6 @@ import {
Paper, Paper,
Typography, Typography,
TextField, TextField,
Select,
MenuItem, MenuItem,
FormControlLabel, FormControlLabel,
Switch, Switch,
@@ -12,9 +11,11 @@ import {
IconButton, IconButton,
Divider, Divider,
} from '@mui/material' } from '@mui/material'
// Select is used internally by TextField with select prop, no need to import
import { ExpandMore, ExpandLess } from '@mui/icons-material' import { ExpandMore, ExpandLess } from '@mui/icons-material'
import { useOptionsStore } from '../stores/optionsStore' import { useOptionsStore } from '../stores/optionsStore'
interface SectionProps { interface SectionProps {
title: string title: string
children: React.ReactNode children: React.ReactNode
@@ -4,7 +4,6 @@ import { useDropzone } from 'react-dropzone'
import { useTracklistStore } from '../stores/tracklistStore' import { useTracklistStore } from '../stores/tracklistStore'
import { useOptionsStore } from '../stores/optionsStore' import { useOptionsStore } from '../stores/optionsStore'
import { parseAndValidateTracklist } from '../utils/validators' import { parseAndValidateTracklist } from '../utils/validators'
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()
+2 -6
View File
@@ -4,7 +4,6 @@ import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
import { CloudUpload, InsertDriveFile } from '@mui/icons-material' import { CloudUpload, InsertDriveFile } from '@mui/icons-material'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { uploadFile } from '../api/client' import { uploadFile } from '../api/client'
import { useTaskStore } from '../stores/taskStore'
const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3'] const ALLOWED_EXTENSIONS = ['.mp3', '.flac', '.wav', '.m4a', '.ogg', '.opus', '.aac', '.wma', '.aiff', '.alac', '.ac3']
@@ -25,8 +24,6 @@ export const UploadZone: React.FC = () => {
setTaskId, setTaskId,
} = useUploadStore() } = useUploadStore()
const { setTaskId: setTaskIdStore, setIsProcessing } = useTaskStore()
const onDrop = useCallback( const onDrop = useCallback(
async (acceptedFiles: File[]) => { async (acceptedFiles: File[]) => {
if (acceptedFiles.length === 0) return if (acceptedFiles.length === 0) return
@@ -49,7 +46,6 @@ export const UploadZone: React.FC = () => {
try { try {
const response = await uploadFile(selectedFile) const response = await uploadFile(selectedFile)
setTaskId(response.task_id) setTaskId(response.task_id)
setTaskIdStore(response.task_id)
setUploadProgress(100) setUploadProgress(100)
setIsUploading(false) setIsUploading(false)
} catch (err: any) { } catch (err: any) {
@@ -61,7 +57,7 @@ export const UploadZone: React.FC = () => {
setFileSize(0) setFileSize(0)
} }
}, },
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore] [setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId]
) )
const { getRootProps, getInputProps, isDragActive } = useDropzone({ const { getRootProps, getInputProps, isDragActive } = useDropzone({
@@ -140,4 +136,4 @@ export const UploadZone: React.FC = () => {
)} )}
</Box> </Box>
) )
} }
+78 -16
View File
@@ -1,11 +1,13 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { useTaskStore } from '../stores/taskStore' import { useTaskStore } from '../stores/taskStore'
import { useUIStore } from '../stores/uiStore' import { useUIStore } from '../stores/uiStore'
import { getStatus } from '../api/client'
export const useWebSocket = (taskId: string | null) => { export const useWebSocket = (taskId: string | null) => {
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null)
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null) const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reconnectAttempts = useRef(0) const reconnectAttempts = useRef(0)
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { const {
setStatus, setStatus,
@@ -19,6 +21,55 @@ export const useWebSocket = (taskId: string | null) => {
} = useTaskStore() } = useTaskStore()
const { setWsConnected } = useUIStore() const { setWsConnected } = useUIStore()
const pollStatus = async () => {
if (!taskId) return
try {
const response = await getStatus(taskId)
console.log('[Polling] Status response:', response)
// Update all state fields together
setStatus(response.status)
setProgress(response.progress)
setMessage(response.message)
// Explicitly set tracks if present
if (response.tracks && response.tracks.length > 0) {
console.log('[Polling] Setting tracks:', response.tracks)
setTracks(response.tracks)
}
if (response.status === 'done') {
console.log('[Polling] Split complete, tracks set:', response.tracks)
setIsProcessing(false)
// Ensure tracks are set one more time (safety)
if (response.tracks && response.tracks.length > 0) {
setTracks(response.tracks)
}
return
}
if (response.status === 'error') {
setError(response.error || 'Split failed')
setIsProcessing(false)
return
}
// Still processing poll again
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
pollingRef.current = setTimeout(pollStatus, 2000)
} catch (error) {
console.error('[Polling] Error:', error)
if (pollingRef.current) {
clearTimeout(pollingRef.current)
}
pollingRef.current = setTimeout(pollStatus, 3000)
}
}
useEffect(() => { useEffect(() => {
if (!taskId) { if (!taskId) {
if (wsRef.current) { if (wsRef.current) {
@@ -26,6 +77,11 @@ export const useWebSocket = (taskId: string | null) => {
wsRef.current = null wsRef.current = null
} }
setWsConnected(false) setWsConnected(false)
// Clear polling
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
return return
} }
@@ -41,11 +97,17 @@ export const useWebSocket = (taskId: string | null) => {
clearTimeout(reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null reconnectTimeoutRef.current = null
} }
// Start polling when connection is established
// This ensures we get the final status even if WebSocket fails
setTimeout(() => {
pollStatus()
}, 1000)
} }
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) const data = JSON.parse(event.data)
console.log('[WebSocket] Message:', data)
if (data.type === 'status') { if (data.type === 'status') {
const statusData = data.data const statusData = data.data
@@ -58,7 +120,6 @@ export const useWebSocket = (taskId: string | null) => {
if (statusData.tracks) { if (statusData.tracks) {
setTracks(statusData.tracks) setTracks(statusData.tracks)
} }
// If status is done or error, stop processing
if (statusData.status === 'done' || statusData.status === 'error') { if (statusData.status === 'done' || statusData.status === 'error') {
setIsProcessing(false) setIsProcessing(false)
} }
@@ -67,13 +128,12 @@ export const useWebSocket = (taskId: string | null) => {
setStatus(progressData.status) setStatus(progressData.status)
setProgress(progressData.progress) setProgress(progressData.progress)
setMessage(progressData.message) setMessage(progressData.message)
if (progressData.tracks) {
setTracks(progressData.tracks)
}
if (progressData.status === 'done') { if (progressData.status === 'done') {
addLog('✅ Split complete!') addLog('✅ Split complete!')
setIsProcessing(false) 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}`)
@@ -83,7 +143,7 @@ export const useWebSocket = (taskId: string | null) => {
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to parse WebSocket message:', error) console.error('[WebSocket] Failed to parse message:', error)
} }
} }
@@ -91,18 +151,16 @@ export const useWebSocket = (taskId: string | null) => {
console.log(`WebSocket disconnected for task ${taskId}`) console.log(`WebSocket disconnected for task ${taskId}`)
setWsConnected(false) setWsConnected(false)
// If task is still in processing state, reconnect // If task is not done and we have a taskId, start polling
// We'll check via polling if needed // We check the status store to see if it's already done
if (reconnectAttempts.current < 5) { if (taskId && status !== 'done' && status !== 'error') {
reconnectTimeoutRef.current = setTimeout(() => { console.log('[WebSocket] Disconnected while processing, starting polling...')
reconnectAttempts.current += 1 setTimeout(pollStatus, 1000)
connect()
}, 3000)
} }
} }
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket error:', error) console.error('[WebSocket] Error:', error)
// onclose will handle reconnection // onclose will handle reconnection
} }
@@ -120,9 +178,13 @@ export const useWebSocket = (taskId: string | null) => {
clearTimeout(reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null reconnectTimeoutRef.current = null
} }
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
}
setWsConnected(false) setWsConnected(false)
} }
}, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing]) }, [taskId, setStatus, setProgress, setMessage, setError, setTracks, addLog, setWsConnected, setIsProcessing, status])
return wsRef.current return wsRef.current
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import { create } from 'zustand' import { create } from 'zustand'
import { TaskStatus, TrackInfo } from '../types' import { TrackInfo } from '../types'
interface TaskState { interface TaskState {
taskId: string | null taskId: string | null
@@ -51,4 +51,4 @@ export const useTaskStore = create<TaskState>((set) => ({
logs: [], logs: [],
isProcessing: false, isProcessing: false,
}), }),
})) }))
-1
View File
@@ -1,5 +1,4 @@
// web/frontend/src/utils/validators.ts // web/frontend/src/utils/validators.ts
import { TracklistEntry } from '../types' import { TracklistEntry } from '../types'
import { parseTracklistWithFormat } from './parser' import { parseTracklistWithFormat } from './parser'
+3 -3
View File
@@ -12,10 +12,10 @@
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": false,
"noUnusedParameters": true, "noUnusedParameters": false,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }