Tracs validation added. A warning is thrown if the input contains a video strem, but the requested output format doesn't support it
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
"""Endpoint to expose format information to the frontend."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from backend.constants import FORMAT_INFO
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["formats"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/formats")
|
||||||
|
async def get_formats():
|
||||||
|
"""
|
||||||
|
Return the list of supported container formats with their properties.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"formats": [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"ffmpeg": info["ffmpeg"],
|
||||||
|
"extension": info["ext"],
|
||||||
|
"audio_only": info["audio_only"],
|
||||||
|
}
|
||||||
|
for name, info in FORMAT_INFO.items()
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Endpoint to retrieve stream information for an uploaded file."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from backend.config import settings
|
||||||
|
from backend.services.file_manager import FileManager
|
||||||
|
from backend.services.task_manager import task_manager
|
||||||
|
from backend.ffmpeg import get_stream_info
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["info"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/info/{task_id}")
|
||||||
|
async def get_task_info(task_id: str):
|
||||||
|
"""
|
||||||
|
Return stream information (has_audio, has_video, has_subtitle) for the uploaded file.
|
||||||
|
"""
|
||||||
|
if not task_manager.has_task(task_id):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||||
|
|
||||||
|
input_path = FileManager.get_input_path(task_id)
|
||||||
|
if not input_path or not input_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Input file not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = get_stream_info(str(input_path))
|
||||||
|
return {
|
||||||
|
"task_id": task_id,
|
||||||
|
"has_audio": info["has_audio"],
|
||||||
|
"has_video": info["has_video"],
|
||||||
|
"has_subtitle": info["has_subtitle"],
|
||||||
|
"audio_codec": info["audio_codec"],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
FORMAT_INFO = {
|
||||||
|
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
|
||||||
|
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
|
||||||
|
'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False},
|
||||||
|
'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||||
|
'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False},
|
||||||
|
'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True},
|
||||||
|
'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True},
|
||||||
|
'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True},
|
||||||
|
'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True},
|
||||||
|
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""FFmpeg/FFprobe interaction utilities for the web backend."""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
def get_stream_info(input_file: str):
|
||||||
|
"""
|
||||||
|
Retrieve stream information (audio, video, subtitle presence) from a media file.
|
||||||
|
Returns a dict with keys: has_audio, has_video, has_subtitle, audio_codec.
|
||||||
|
"""
|
||||||
|
# Get audio codec (if any)
|
||||||
|
audio_codec = None
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
'ffprobe', '-v', 'error',
|
||||||
|
'-select_streams', 'a:0',
|
||||||
|
'-show_entries', 'stream=codec_name',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
input_file
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
codec = result.stdout.strip().lower()
|
||||||
|
if codec:
|
||||||
|
audio_codec = codec
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for video stream
|
||||||
|
has_video = False
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
'ffprobe', '-v', 'error',
|
||||||
|
'-select_streams', 'v',
|
||||||
|
'-show_entries', 'stream=codec_type',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
input_file
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
has_video = bool(result.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for subtitle stream
|
||||||
|
has_subtitle = False
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
'ffprobe', '-v', 'error',
|
||||||
|
'-select_streams', 's',
|
||||||
|
'-show_entries', 'stream=codec_type',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
input_file
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
has_subtitle = bool(result.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
'has_audio': audio_codec is not None,
|
||||||
|
'has_video': has_video,
|
||||||
|
'has_subtitle': has_subtitle,
|
||||||
|
'audio_codec': audio_codec,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_duration(input_file: str) -> float:
|
||||||
|
"""Get the duration of the audio file in seconds."""
|
||||||
|
cmd = [
|
||||||
|
'ffprobe', '-v', 'error',
|
||||||
|
'-show_entries', 'format=duration',
|
||||||
|
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||||
|
input_file
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=False)
|
||||||
|
try:
|
||||||
|
return float(result.stdout.strip())
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
+4
-3
@@ -5,8 +5,7 @@ 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, websocket, formats, info
|
||||||
from backend.api.websocket import router as websocket_router
|
|
||||||
from backend.services import progress_publisher
|
from backend.services import progress_publisher
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -36,7 +35,9 @@ app.include_router(upload.router)
|
|||||||
app.include_router(split.router)
|
app.include_router(split.router)
|
||||||
app.include_router(status.router)
|
app.include_router(status.router)
|
||||||
app.include_router(download.router)
|
app.include_router(download.router)
|
||||||
app.include_router(websocket_router)
|
app.include_router(websocket.router)
|
||||||
|
app.include_router(formats.router) # new
|
||||||
|
app.include_router(info.router) # new
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
+13
-12
@@ -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, CircularProgress } from '@mui/material'
|
import { Box, Grid, Button, CircularProgress, Typography } 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'
|
||||||
@@ -13,6 +13,7 @@ import { useTracklistStore } from './stores/tracklistStore'
|
|||||||
import { useOptionsStore } from './stores/optionsStore'
|
import { useOptionsStore } from './stores/optionsStore'
|
||||||
import { useTaskStore } from './stores/taskStore'
|
import { useTaskStore } from './stores/taskStore'
|
||||||
import { useUIStore } from './stores/uiStore'
|
import { useUIStore } from './stores/uiStore'
|
||||||
|
import { useValidationStore } from './stores/validationStore'
|
||||||
import { useWebSocket } from './hooks/useWebSocket'
|
import { useWebSocket } from './hooks/useWebSocket'
|
||||||
import { startSplit } from './api/client'
|
import { startSplit } from './api/client'
|
||||||
|
|
||||||
@@ -23,14 +24,14 @@ const App: React.FC = () => {
|
|||||||
const { options } = useOptionsStore()
|
const { options } = useOptionsStore()
|
||||||
const {
|
const {
|
||||||
isProcessing,
|
isProcessing,
|
||||||
setTaskId, // <-- Add this
|
setTaskId,
|
||||||
setError,
|
setError,
|
||||||
setIsProcessing,
|
setIsProcessing,
|
||||||
addLog,
|
addLog,
|
||||||
reset,
|
reset,
|
||||||
} = useTaskStore()
|
} = useTaskStore()
|
||||||
|
const { formatError } = useValidationStore()
|
||||||
|
|
||||||
// Connect WebSocket when taskId is available and processing
|
|
||||||
useWebSocket(taskId && isProcessing ? taskId : null)
|
useWebSocket(taskId && isProcessing ? taskId : null)
|
||||||
|
|
||||||
const handleSplit = async () => {
|
const handleSplit = async () => {
|
||||||
@@ -50,7 +51,6 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Set taskId in taskStore so DownloadSection can use it
|
|
||||||
setTaskId(taskId)
|
setTaskId(taskId)
|
||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
addLog('🚀 Starting split...')
|
addLog('🚀 Starting split...')
|
||||||
@@ -68,7 +68,7 @@ const App: React.FC = () => {
|
|||||||
reset()
|
reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing
|
const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
@@ -81,22 +81,15 @@ const App: React.FC = () => {
|
|||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Layout>
|
<Layout>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
{/* Upload Section */}
|
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<UploadZone />
|
<UploadZone />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Tracklist Editor */}
|
|
||||||
<Grid item xs={12} md={6}>
|
<Grid item xs={12} md={6}>
|
||||||
<TracklistEditor />
|
<TracklistEditor />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Options Panel */}
|
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<OptionsPanel />
|
<OptionsPanel />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Progress / Split Controls */}
|
|
||||||
<Grid item xs={12} md={8}>
|
<Grid item xs={12} md={8}>
|
||||||
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
<Box sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||||
<Button
|
<Button
|
||||||
@@ -114,6 +107,14 @@ const App: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{formatError && (
|
||||||
|
<Box sx={{ mb: 2, p: 2, bgcolor: 'warning.light', borderRadius: 1 }}>
|
||||||
|
<Typography color="warning.dark" variant="body2">
|
||||||
|
⚠️ {formatError}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<ProgressDisplay />
|
<ProgressDisplay />
|
||||||
<DownloadSection />
|
<DownloadSection />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -46,3 +46,20 @@ 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`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New functions for format validation feature
|
||||||
|
export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => {
|
||||||
|
const response = await api.get('/formats')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTaskInfo = async (task_id: string): Promise<{
|
||||||
|
task_id: string
|
||||||
|
has_audio: boolean
|
||||||
|
has_video: boolean
|
||||||
|
has_subtitle: boolean
|
||||||
|
audio_codec: string | null
|
||||||
|
}> => {
|
||||||
|
const response = await api.get(`/info/${task_id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
Collapse,
|
Collapse,
|
||||||
IconButton,
|
IconButton,
|
||||||
Divider,
|
Divider,
|
||||||
|
Alert,
|
||||||
} 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'
|
||||||
|
import { useUploadStore } from '../stores/uploadStore'
|
||||||
|
import { useValidationStore } from '../stores/validationStore'
|
||||||
|
|
||||||
interface SectionProps {
|
interface SectionProps {
|
||||||
title: string
|
title: string
|
||||||
@@ -52,41 +53,75 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
|
|||||||
|
|
||||||
export const OptionsPanel: React.FC = () => {
|
export const OptionsPanel: React.FC = () => {
|
||||||
const { options, setOptions } = useOptionsStore()
|
const { options, setOptions } = useOptionsStore()
|
||||||
|
const { hasVideo } = useUploadStore()
|
||||||
|
const { formatError, setFormatError } = useValidationStore()
|
||||||
|
|
||||||
const handleChange = (field: string, value: any) => {
|
// Audio-only formats from backend constants (hardcoded for now)
|
||||||
setOptions({ [field]: value })
|
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
|
||||||
|
|
||||||
|
const handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newFormat = e.target.value
|
||||||
|
setOptions({ format: newFormat })
|
||||||
|
|
||||||
|
// Validate format
|
||||||
|
if (audioOnlyFormats.includes(newFormat) && hasVideo && !options.drop_video) {
|
||||||
|
setFormatError(
|
||||||
|
`Format '${newFormat}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setFormatError(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const checked = e.target.checked
|
||||||
|
setOptions({ drop_video: checked })
|
||||||
|
// Re-validate format
|
||||||
|
if (audioOnlyFormats.includes(options.format) && hasVideo && !checked) {
|
||||||
|
setFormatError(
|
||||||
|
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setFormatError(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-validate when hasVideo changes (e.g., after upload)
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldShowError = audioOnlyFormats.includes(options.format) && hasVideo && !options.drop_video
|
||||||
|
const newError = shouldShowError
|
||||||
|
? `Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||||
|
: null
|
||||||
|
|
||||||
|
// Only update if the error state actually changes
|
||||||
|
if (newError !== formatError) {
|
||||||
|
setFormatError(newError)
|
||||||
|
}
|
||||||
|
}, [hasVideo, options.format, options.drop_video, formatError, setFormatError])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ p: 3 }}>
|
<Paper sx={{ p: 3 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||||
⚙️ Options
|
⚙️ Options
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
{/* Tracklist Section */}
|
{formatError && (
|
||||||
<Section title="Tracklist Settings" defaultExpanded>
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
{formatError}
|
||||||
<TextField
|
</Alert>
|
||||||
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 Settings */}
|
||||||
<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 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Format"
|
label="Format"
|
||||||
select
|
select
|
||||||
value={options.format}
|
value={options.format}
|
||||||
onChange={(e) => handleChange('format', e.target.value)}
|
onChange={handleFormatChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
|
error={!!formatError}
|
||||||
>
|
>
|
||||||
<MenuItem value="mp3">MP3</MenuItem>
|
<MenuItem value="mp3">MP3</MenuItem>
|
||||||
<MenuItem value="m4a">M4A</MenuItem>
|
<MenuItem value="m4a">M4A</MenuItem>
|
||||||
@@ -113,15 +148,18 @@ export const OptionsPanel: React.FC = () => {
|
|||||||
<MenuItem value="libopus">OPUS</MenuItem>
|
<MenuItem value="libopus">OPUS</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
<FormControlLabel
|
{hasVideo && (
|
||||||
control={
|
<FormControlLabel
|
||||||
<Switch
|
control={
|
||||||
checked={options.drop_video}
|
<Switch
|
||||||
onChange={(e) => handleChange('drop_video', e.target.checked)}
|
checked={options.drop_video}
|
||||||
/>
|
onChange={handleDropVideoChange}
|
||||||
}
|
/>
|
||||||
label="Drop video streams"
|
}
|
||||||
/>
|
label="Drop video streams"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Switch
|
<Switch
|
||||||
@@ -134,7 +172,7 @@ export const OptionsPanel: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Filename Section */}
|
{/* Filename Settings */}
|
||||||
<Section title="Filename Settings">
|
<Section title="Filename Settings">
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -190,7 +228,7 @@ export const OptionsPanel: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* Metadata Section */}
|
{/* Metadata Settings */}
|
||||||
<Section title="Metadata Settings">
|
<Section title="Metadata Settings">
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -245,8 +283,25 @@ export const OptionsPanel: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
{/* Tracklist Settings */}
|
||||||
|
<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>
|
||||||
</Paper>
|
</Paper>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Helper function for option updates
|
||||||
|
function handleChange(field: string, value: any) {
|
||||||
|
setOptions({ [field]: value })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useDropzone } from 'react-dropzone'
|
|||||||
import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material'
|
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, getTaskInfo } 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']
|
||||||
|
|
||||||
@@ -22,8 +23,14 @@ export const UploadZone: React.FC = () => {
|
|||||||
setUploadProgress,
|
setUploadProgress,
|
||||||
setError,
|
setError,
|
||||||
setTaskId,
|
setTaskId,
|
||||||
|
setHasVideo,
|
||||||
|
setHasAudio,
|
||||||
|
setHasSubtitle,
|
||||||
|
setAudioCodec,
|
||||||
} = useUploadStore()
|
} = useUploadStore()
|
||||||
|
|
||||||
|
const { setTaskId: setTaskIdStore } = useTaskStore()
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
async (acceptedFiles: File[]) => {
|
async (acceptedFiles: File[]) => {
|
||||||
if (acceptedFiles.length === 0) return
|
if (acceptedFiles.length === 0) return
|
||||||
@@ -45,9 +52,23 @@ export const UploadZone: React.FC = () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await uploadFile(selectedFile)
|
const response = await uploadFile(selectedFile)
|
||||||
setTaskId(response.task_id)
|
const taskId = response.task_id
|
||||||
|
setTaskId(taskId)
|
||||||
|
setTaskIdStore(taskId)
|
||||||
setUploadProgress(100)
|
setUploadProgress(100)
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
|
|
||||||
|
// Fetch stream info
|
||||||
|
try {
|
||||||
|
const info = await getTaskInfo(taskId)
|
||||||
|
setHasVideo(info.has_video)
|
||||||
|
setHasAudio(info.has_audio)
|
||||||
|
setHasSubtitle(info.has_subtitle)
|
||||||
|
setAudioCodec(info.audio_codec)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch stream info:', err)
|
||||||
|
// Don't block the upload flow if this fails; we'll just assume no video
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || err.message || 'Upload failed')
|
setError(err.response?.data?.detail || err.message || 'Upload failed')
|
||||||
setIsUploading(false)
|
setIsUploading(false)
|
||||||
@@ -57,7 +78,7 @@ export const UploadZone: React.FC = () => {
|
|||||||
setFileSize(0)
|
setFileSize(0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId]
|
[setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ interface UploadState {
|
|||||||
isUploading: boolean
|
isUploading: boolean
|
||||||
uploadProgress: number
|
uploadProgress: number
|
||||||
error: string | null
|
error: string | null
|
||||||
|
// New fields for stream info
|
||||||
|
hasVideo: boolean
|
||||||
|
hasAudio: boolean
|
||||||
|
hasSubtitle: boolean
|
||||||
|
audioCodec: string | null
|
||||||
|
|
||||||
setFile: (file: File | null) => void
|
setFile: (file: File | null) => void
|
||||||
setTaskId: (taskId: string | null) => void
|
setTaskId: (taskId: string | null) => void
|
||||||
@@ -16,6 +21,10 @@ interface UploadState {
|
|||||||
setIsUploading: (isUploading: boolean) => void
|
setIsUploading: (isUploading: boolean) => void
|
||||||
setUploadProgress: (progress: number) => void
|
setUploadProgress: (progress: number) => void
|
||||||
setError: (error: string | null) => void
|
setError: (error: string | null) => void
|
||||||
|
setHasVideo: (hasVideo: boolean) => void
|
||||||
|
setHasAudio: (hasAudio: boolean) => void
|
||||||
|
setHasSubtitle: (hasSubtitle: boolean) => void
|
||||||
|
setAudioCodec: (audioCodec: string | null) => void
|
||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +36,10 @@ export const useUploadStore = create<UploadState>((set) => ({
|
|||||||
isUploading: false,
|
isUploading: false,
|
||||||
uploadProgress: 0,
|
uploadProgress: 0,
|
||||||
error: null,
|
error: null,
|
||||||
|
hasVideo: false,
|
||||||
|
hasAudio: false,
|
||||||
|
hasSubtitle: false,
|
||||||
|
audioCodec: null,
|
||||||
|
|
||||||
setFile: (file) => set({ file }),
|
setFile: (file) => set({ file }),
|
||||||
setTaskId: (taskId) => set({ taskId }),
|
setTaskId: (taskId) => set({ taskId }),
|
||||||
@@ -35,6 +48,10 @@ export const useUploadStore = create<UploadState>((set) => ({
|
|||||||
setIsUploading: (isUploading) => set({ isUploading }),
|
setIsUploading: (isUploading) => set({ isUploading }),
|
||||||
setUploadProgress: (uploadProgress) => set({ uploadProgress }),
|
setUploadProgress: (uploadProgress) => set({ uploadProgress }),
|
||||||
setError: (error) => set({ error }),
|
setError: (error) => set({ error }),
|
||||||
|
setHasVideo: (hasVideo) => set({ hasVideo }),
|
||||||
|
setHasAudio: (hasAudio) => set({ hasAudio }),
|
||||||
|
setHasSubtitle: (hasSubtitle) => set({ hasSubtitle }),
|
||||||
|
setAudioCodec: (audioCodec) => set({ audioCodec }),
|
||||||
reset: () =>
|
reset: () =>
|
||||||
set({
|
set({
|
||||||
file: null,
|
file: null,
|
||||||
@@ -44,5 +61,9 @@ export const useUploadStore = create<UploadState>((set) => ({
|
|||||||
isUploading: false,
|
isUploading: false,
|
||||||
uploadProgress: 0,
|
uploadProgress: 0,
|
||||||
error: null,
|
error: null,
|
||||||
|
hasVideo: false,
|
||||||
|
hasAudio: false,
|
||||||
|
hasSubtitle: false,
|
||||||
|
audioCodec: null,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
interface ValidationState {
|
||||||
|
formatError: string | null
|
||||||
|
setFormatError: (error: string | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useValidationStore = create<ValidationState>((set) => ({
|
||||||
|
formatError: null,
|
||||||
|
setFormatError: (error) => set({ formatError: error }),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user