Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d08045bf02 | |||
| 4843ac1cb5 | |||
| 364fc7a8fe | |||
| 8a1e2a5335 |
+39
-2
@@ -5,7 +5,12 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from backend.services.file_manager import FileManager
|
from backend.services.file_manager import FileManager
|
||||||
from backend.services.task_manager import task_manager
|
from backend.services.task_manager import task_manager
|
||||||
from backend.ffmpeg import get_stream_info
|
|
||||||
|
# Import core functions
|
||||||
|
from audio_splitter.ffmpeg import get_stream_info, get_container_format, get_audio_codec
|
||||||
|
from audio_splitter.formats import determine_default_format
|
||||||
|
from audio_splitter.constants import FORMAT_INFO
|
||||||
|
from audio_splitter.defaults import DEFAULT_FORMAT
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["info"])
|
router = APIRouter(prefix="/api", tags=["info"])
|
||||||
|
|
||||||
@@ -13,7 +18,8 @@ router = APIRouter(prefix="/api", tags=["info"])
|
|||||||
@router.get("/info/{task_id}")
|
@router.get("/info/{task_id}")
|
||||||
async def get_task_info(task_id: str):
|
async def get_task_info(task_id: str):
|
||||||
"""
|
"""
|
||||||
Return stream information (has_audio, has_video, has_subtitle) for the uploaded file.
|
Return stream information (has_audio, has_video, has_subtitle, audio_codec)
|
||||||
|
for the uploaded file.
|
||||||
"""
|
"""
|
||||||
if not task_manager.has_task(task_id):
|
if not task_manager.has_task(task_id):
|
||||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||||
@@ -33,3 +39,34 @@ async def get_task_info(task_id: str):
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to retrieve stream info: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/info/recommended-format/{task_id}")
|
||||||
|
async def get_recommended_format(task_id: str):
|
||||||
|
"""
|
||||||
|
Return the recommended output format (container name) for the uploaded file,
|
||||||
|
based on its container and audio codec.
|
||||||
|
"""
|
||||||
|
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:
|
||||||
|
container = get_container_format(str(input_path))
|
||||||
|
codec = get_audio_codec(str(input_path))
|
||||||
|
|
||||||
|
fmt = determine_default_format(container, codec)
|
||||||
|
|
||||||
|
# Fallback if detection fails or format is unsupported
|
||||||
|
if fmt is None:
|
||||||
|
fmt = DEFAULT_FORMAT
|
||||||
|
if fmt not in FORMAT_INFO:
|
||||||
|
fmt = "mp3" # ultimate fallback
|
||||||
|
|
||||||
|
return {"format": fmt}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to determine format: {str(e)}")
|
||||||
|
|||||||
@@ -63,3 +63,8 @@ export const getTaskInfo = async (task_id: string): Promise<{
|
|||||||
const response = await api.get(`/info/${task_id}`)
|
const response = await api.get(`/info/${task_id}`)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getRecommendedFormat = async (task_id: string): Promise<{ format: string }> => {
|
||||||
|
const response = await api.get(`/info/recommended-format/${task_id}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,32 +115,37 @@ export const OptionsPanel: React.FC = () => {
|
|||||||
<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="Container"
|
||||||
select
|
select
|
||||||
value={options.format}
|
value={options.format}
|
||||||
onChange={handleFormatChange}
|
onChange={handleFormatChange}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
error={!!formatError}
|
error={!!formatError}
|
||||||
|
helperText={
|
||||||
|
formatError || "Determines the output file extension and container structure."
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<MenuItem value="mp3">MP3</MenuItem>
|
<MenuItem value="mp3">MP3 (.mp3)</MenuItem>
|
||||||
<MenuItem value="m4a">M4A</MenuItem>
|
<MenuItem value="m4a">M4A (.m4a)</MenuItem>
|
||||||
<MenuItem value="mkv">MKV</MenuItem>
|
<MenuItem value="mkv">MKV (.mkv)</MenuItem>
|
||||||
<MenuItem value="mp4">MP4</MenuItem>
|
<MenuItem value="mp4">MP4 (.mp4)</MenuItem>
|
||||||
<MenuItem value="ogg">OGG</MenuItem>
|
<MenuItem value="ogg">OGG (.ogg)</MenuItem>
|
||||||
<MenuItem value="opus">OPUS</MenuItem>
|
<MenuItem value="opus">OPUS (.opus)</MenuItem>
|
||||||
<MenuItem value="flac">FLAC</MenuItem>
|
<MenuItem value="flac">FLAC (.flac)</MenuItem>
|
||||||
<MenuItem value="wav">WAV</MenuItem>
|
<MenuItem value="wav">WAV (.wav)</MenuItem>
|
||||||
<MenuItem value="aac">AAC</MenuItem>
|
<MenuItem value="aac">AAC (.aac)</MenuItem>
|
||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
|
{/* Transcode option – remains unchanged but clearly labeled */}
|
||||||
<TextField
|
<TextField
|
||||||
label="Transcode to"
|
label="Audio Codec (Transcode)"
|
||||||
select
|
select
|
||||||
value={options.transcode_to || ''}
|
value={options.transcode_to || ''}
|
||||||
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
onChange={(e) => handleChange('transcode_to', e.target.value || undefined)}
|
||||||
fullWidth
|
fullWidth
|
||||||
size="small"
|
size="small"
|
||||||
|
helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original."
|
||||||
>
|
>
|
||||||
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
||||||
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react'
|
import React, { useCallback, useEffect, useRef, 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'
|
||||||
@@ -10,6 +10,9 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
const { options } = useOptionsStore()
|
const { options } = useOptionsStore()
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
|
const textAreaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const lineNumbersRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const validate = (text: string) => {
|
const validate = (text: string) => {
|
||||||
const result = parseAndValidateTracklist(text, options.tracklist_format)
|
const result = parseAndValidateTracklist(text, options.tracklist_format)
|
||||||
setEntries(result.entries)
|
setEntries(result.entries)
|
||||||
@@ -23,10 +26,29 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
validate(text)
|
validate(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
if (lineNumbersRef.current && textAreaRef.current) {
|
||||||
|
lineNumbersRef.current.scrollTop = textAreaRef.current.scrollTop
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const textArea = textAreaRef.current
|
||||||
|
if (textArea) {
|
||||||
|
textArea.addEventListener('scroll', handleScroll)
|
||||||
|
return () => textArea.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}, [handleScroll])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rawText) {
|
||||||
|
validate(rawText)
|
||||||
|
}
|
||||||
|
}, [options.tracklist_format])
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(acceptedFiles: File[]) => {
|
(acceptedFiles: File[]) => {
|
||||||
if (acceptedFiles.length === 0) return
|
if (acceptedFiles.length === 0) return
|
||||||
|
|
||||||
const file = acceptedFiles[0]
|
const file = acceptedFiles[0]
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
@@ -48,13 +70,6 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
multiple: false,
|
multiple: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-validate when tracklist format changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (rawText) {
|
|
||||||
validate(rawText)
|
|
||||||
}
|
|
||||||
}, [options.tracklist_format])
|
|
||||||
|
|
||||||
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
|
const lineCount = rawText.split('\n').filter(line => line.trim() !== '').length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -82,12 +97,13 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2, position: 'relative' }}>
|
||||||
{/* Line numbers column */}
|
{/* Line numbers column */}
|
||||||
<Box
|
<Box
|
||||||
|
ref={lineNumbersRef}
|
||||||
sx={{
|
sx={{
|
||||||
minWidth: 40,
|
minWidth: 40,
|
||||||
maxWidth: 40,
|
maxWidth: 60, // Allow more space for 3-digit numbers
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
@@ -95,6 +111,11 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
paddingTop: '8.5px',
|
||||||
|
paddingBottom: '8.5px',
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
'&::-webkit-scrollbar': { display: 'none' },
|
||||||
|
whiteSpace: 'nowrap', // Prevent wrapping of line numbers
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rawText.split('\n').map((_, i) => (
|
{rawText.split('\n').map((_, i) => (
|
||||||
@@ -102,7 +123,7 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Editor text area */}
|
{/* Editor text area – now with horizontal scroll and no wrap */}
|
||||||
<TextField
|
<TextField
|
||||||
multiline
|
multiline
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -112,11 +133,19 @@ export const TracklistEditor: React.FC = () => {
|
|||||||
onChange={handleTextChange}
|
onChange={handleTextChange}
|
||||||
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`}
|
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"
|
||||||
|
inputRef={textAreaRef}
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiInputBase-root': {
|
'& .MuiInputBase-root': {
|
||||||
fontFamily: 'monospace',
|
fontFamily: 'monospace',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
lineHeight: 1.7,
|
lineHeight: 1.7,
|
||||||
|
overflowX: 'auto', // Enable horizontal scroll
|
||||||
|
},
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
paddingTop: '8.5px',
|
||||||
|
paddingBottom: '8.5px',
|
||||||
|
whiteSpace: 'nowrap', // Prevent wrapping
|
||||||
|
overflowX: 'auto',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
error={!isValid && errors.length > 0}
|
error={!isValid && errors.length > 0}
|
||||||
|
|||||||
@@ -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, getTaskInfo } from '../api/client'
|
import { useOptionsStore } from '../stores/optionsStore' // NEW
|
||||||
|
import { uploadFile, getTaskInfo, getRecommendedFormat } from '../api/client' // NEW
|
||||||
import { useTaskStore } from '../stores/taskStore'
|
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']
|
||||||
@@ -30,6 +31,7 @@ export const UploadZone: React.FC = () => {
|
|||||||
} = useUploadStore()
|
} = useUploadStore()
|
||||||
|
|
||||||
const { setTaskId: setTaskIdStore } = useTaskStore()
|
const { setTaskId: setTaskIdStore } = useTaskStore()
|
||||||
|
const { setOptions } = useOptionsStore() // NEW
|
||||||
|
|
||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
async (acceptedFiles: File[]) => {
|
async (acceptedFiles: File[]) => {
|
||||||
@@ -65,9 +67,18 @@ export const UploadZone: React.FC = () => {
|
|||||||
setHasAudio(info.has_audio)
|
setHasAudio(info.has_audio)
|
||||||
setHasSubtitle(info.has_subtitle)
|
setHasSubtitle(info.has_subtitle)
|
||||||
setAudioCodec(info.audio_codec)
|
setAudioCodec(info.audio_codec)
|
||||||
|
|
||||||
|
// Fetch recommended format and update options
|
||||||
|
try {
|
||||||
|
const rec = await getRecommendedFormat(taskId)
|
||||||
|
// Update only the format; keep other options (e.g., transcode_to) as defaults
|
||||||
|
setOptions({ format: rec.format })
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch recommended format, using default', err)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch stream info:', err)
|
console.error('Failed to fetch stream info:', err)
|
||||||
// Don't block the upload flow if this fails; we'll just assume no video
|
// Don't block upload flow; user can manually change options
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.detail || err.message || 'Upload failed')
|
setError(err.response?.data?.detail || err.message || 'Upload failed')
|
||||||
@@ -78,7 +89,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, setTaskIdStore, setHasVideo, setHasAudio, setHasSubtitle, setAudioCodec, setOptions]
|
||||||
)
|
)
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
|||||||
Reference in New Issue
Block a user