diff --git a/web/backend/api/formats.py b/web/backend/api/formats.py new file mode 100644 index 0000000..0c0a995 --- /dev/null +++ b/web/backend/api/formats.py @@ -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() + ] + } diff --git a/web/backend/api/info.py b/web/backend/api/info.py new file mode 100644 index 0000000..261fa80 --- /dev/null +++ b/web/backend/api/info.py @@ -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)}") diff --git a/web/backend/constants.py b/web/backend/constants.py new file mode 100644 index 0000000..c7808f3 --- /dev/null +++ b/web/backend/constants.py @@ -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}, +} diff --git a/web/backend/ffmpeg.py b/web/backend/ffmpeg.py new file mode 100644 index 0000000..0cdc777 --- /dev/null +++ b/web/backend/ffmpeg.py @@ -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 diff --git a/web/backend/main.py b/web/backend/main.py index c4d48b8..638051b 100644 --- a/web/backend/main.py +++ b/web/backend/main.py @@ -5,8 +5,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from backend.config import settings -from backend.api import upload, split, status, download -from backend.api.websocket import router as websocket_router +from backend.api import upload, split, status, download, websocket, formats, info from backend.services import progress_publisher app = FastAPI( @@ -36,7 +35,9 @@ app.include_router(upload.router) app.include_router(split.router) app.include_router(status.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("/") diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 89b93f9..7c48eac 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -1,6 +1,6 @@ import React from 'react' 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 { Layout } from './components/Layout' import { UploadZone } from './components/UploadZone' @@ -13,6 +13,7 @@ import { useTracklistStore } from './stores/tracklistStore' import { useOptionsStore } from './stores/optionsStore' import { useTaskStore } from './stores/taskStore' import { useUIStore } from './stores/uiStore' +import { useValidationStore } from './stores/validationStore' import { useWebSocket } from './hooks/useWebSocket' import { startSplit } from './api/client' @@ -23,14 +24,14 @@ const App: React.FC = () => { const { options } = useOptionsStore() const { isProcessing, - setTaskId, // <-- Add this + setTaskId, setError, setIsProcessing, addLog, reset, } = useTaskStore() + const { formatError } = useValidationStore() - // Connect WebSocket when taskId is available and processing useWebSocket(taskId && isProcessing ? taskId : null) const handleSplit = async () => { @@ -50,7 +51,6 @@ const App: React.FC = () => { } try { - // Set taskId in taskStore so DownloadSection can use it setTaskId(taskId) setIsProcessing(true) addLog('🚀 Starting split...') @@ -68,7 +68,7 @@ const App: React.FC = () => { reset() } - const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing + const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError return ( { - {/* Upload Section */} - - {/* Tracklist Editor */} - - {/* Options Panel */} - - {/* Progress / Split Controls */} + {formatError && ( + + + ⚠️ {formatError} + + + )} + diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts index e9375d6..39cb635 100644 --- a/web/frontend/src/api/client.ts +++ b/web/frontend/src/api/client.ts @@ -46,3 +46,20 @@ export const getDownloadUrl = (task_id: string): string => { export const getDownloadZipUrl = (task_id: string): string => { 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 +} diff --git a/web/frontend/src/components/OptionsPanel.tsx b/web/frontend/src/components/OptionsPanel.tsx index a10dcdf..62e4732 100644 --- a/web/frontend/src/components/OptionsPanel.tsx +++ b/web/frontend/src/components/OptionsPanel.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useEffect } from 'react' import { Box, Paper, @@ -10,11 +10,12 @@ import { Collapse, IconButton, Divider, + Alert, } from '@mui/material' -// Select is used internally by TextField with select prop, no need to import import { ExpandMore, ExpandLess } from '@mui/icons-material' import { useOptionsStore } from '../stores/optionsStore' - +import { useUploadStore } from '../stores/uploadStore' +import { useValidationStore } from '../stores/validationStore' interface SectionProps { title: string @@ -52,41 +53,75 @@ const Section: React.FC = ({ title, children, defaultExpanded = fa export const OptionsPanel: React.FC = () => { const { options, setOptions } = useOptionsStore() + const { hasVideo } = useUploadStore() + const { formatError, setFormatError } = useValidationStore() - const handleChange = (field: string, value: any) => { - setOptions({ [field]: value }) + // Audio-only formats from backend constants (hardcoded for now) + const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac'] + + const handleFormatChange = (e: React.ChangeEvent) => { + 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) => { + 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 ( ⚙️ Options - {/* Tracklist Section */} -
- - handleChange('tracklist_format', e.target.value)} - fullWidth - size="small" - helperText="Placeholders: %ts (timestamp), %tn (track name), %an (author), %al (album), %date, %ext" - /> - -
+ {formatError && ( + + {formatError} + + )} - {/* Output Section */} + {/* Output Settings */}
handleChange('format', e.target.value)} + onChange={handleFormatChange} fullWidth size="small" + error={!!formatError} > MP3 M4A @@ -113,15 +148,18 @@ export const OptionsPanel: React.FC = () => { OPUS - handleChange('drop_video', e.target.checked)} - /> - } - label="Drop video streams" - /> + {hasVideo && ( + + } + label="Drop video streams" + /> + )} + {
- {/* Filename Section */} + {/* Filename Settings */}
{
- {/* Metadata Section */} + {/* Metadata Settings */}
{ />
+ + {/* Tracklist Settings */} +
+ + handleChange('tracklist_format', e.target.value)} + fullWidth + size="small" + helperText="Placeholders: %ts (timestamp), %tn (track name), %an (author), %al (album), %date, %ext" + /> + +
) + + // Helper function for option updates + function handleChange(field: string, value: any) { + setOptions({ [field]: value }) + } } - - diff --git a/web/frontend/src/components/UploadZone.tsx b/web/frontend/src/components/UploadZone.tsx index 433dd74..4432f85 100644 --- a/web/frontend/src/components/UploadZone.tsx +++ b/web/frontend/src/components/UploadZone.tsx @@ -3,7 +3,8 @@ import { useDropzone } from 'react-dropzone' import { Box, Typography, Paper, LinearProgress, Alert } from '@mui/material' import { CloudUpload, InsertDriveFile } from '@mui/icons-material' 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'] @@ -22,8 +23,14 @@ export const UploadZone: React.FC = () => { setUploadProgress, setError, setTaskId, + setHasVideo, + setHasAudio, + setHasSubtitle, + setAudioCodec, } = useUploadStore() + const { setTaskId: setTaskIdStore } = useTaskStore() + const onDrop = useCallback( async (acceptedFiles: File[]) => { if (acceptedFiles.length === 0) return @@ -45,9 +52,23 @@ export const UploadZone: React.FC = () => { try { const response = await uploadFile(selectedFile) - setTaskId(response.task_id) + const taskId = response.task_id + setTaskId(taskId) + setTaskIdStore(taskId) setUploadProgress(100) 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) { setError(err.response?.data?.detail || err.message || 'Upload failed') setIsUploading(false) @@ -57,7 +78,7 @@ export const UploadZone: React.FC = () => { setFileSize(0) } }, - [setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId] + [setFile, setFileName, setFileSize, setIsUploading, setUploadProgress, setError, setTaskId, setTaskIdStore] ) const { getRootProps, getInputProps, isDragActive } = useDropzone({ diff --git a/web/frontend/src/stores/uploadStore.ts b/web/frontend/src/stores/uploadStore.ts index addc23e..63ad49c 100644 --- a/web/frontend/src/stores/uploadStore.ts +++ b/web/frontend/src/stores/uploadStore.ts @@ -8,6 +8,11 @@ interface UploadState { isUploading: boolean uploadProgress: number error: string | null + // New fields for stream info + hasVideo: boolean + hasAudio: boolean + hasSubtitle: boolean + audioCodec: string | null setFile: (file: File | null) => void setTaskId: (taskId: string | null) => void @@ -16,6 +21,10 @@ interface UploadState { setIsUploading: (isUploading: boolean) => void setUploadProgress: (progress: number) => 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 } @@ -27,6 +36,10 @@ export const useUploadStore = create((set) => ({ isUploading: false, uploadProgress: 0, error: null, + hasVideo: false, + hasAudio: false, + hasSubtitle: false, + audioCodec: null, setFile: (file) => set({ file }), setTaskId: (taskId) => set({ taskId }), @@ -35,6 +48,10 @@ export const useUploadStore = create((set) => ({ setIsUploading: (isUploading) => set({ isUploading }), setUploadProgress: (uploadProgress) => set({ uploadProgress }), setError: (error) => set({ error }), + setHasVideo: (hasVideo) => set({ hasVideo }), + setHasAudio: (hasAudio) => set({ hasAudio }), + setHasSubtitle: (hasSubtitle) => set({ hasSubtitle }), + setAudioCodec: (audioCodec) => set({ audioCodec }), reset: () => set({ file: null, @@ -44,5 +61,9 @@ export const useUploadStore = create((set) => ({ isUploading: false, uploadProgress: 0, error: null, + hasVideo: false, + hasAudio: false, + hasSubtitle: false, + audioCodec: null, }), -})) \ No newline at end of file +})) diff --git a/web/frontend/src/stores/validationStore.ts b/web/frontend/src/stores/validationStore.ts new file mode 100644 index 0000000..e6b67f2 --- /dev/null +++ b/web/frontend/src/stores/validationStore.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand' + +interface ValidationState { + formatError: string | null + setFormatError: (error: string | null) => void +} + +export const useValidationStore = create((set) => ({ + formatError: null, + setFormatError: (error) => set({ formatError: error }), +}))