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:
2026-08-03 11:26:17 +05:00
parent 1d1f8563c0
commit dcdae41706
11 changed files with 330 additions and 52 deletions
+25
View File
@@ -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()
]
}
+35
View File
@@ -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)}")
+12
View File
@@ -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},
}
+79
View File
@@ -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
View File
@@ -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
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, 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>
+17
View File
@@ -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
}
+80 -25
View File
@@ -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,10 +53,51 @@ 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 }}>
@@ -63,30 +105,23 @@ export const OptionsPanel: React.FC = () => {
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>
{hasVideo && (
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
checked={options.drop_video} checked={options.drop_video}
onChange={(e) => handleChange('drop_video', e.target.checked)} 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 })
}
} }
+24 -3
View File
@@ -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({
+21
View File
@@ -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 }),
}))