FEATURE: all versions use single source of truth for codec/container/file_extension info
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
# web/backend/api/formats.py
|
||||
"""Endpoint to expose format information to the frontend."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.constants import FORMAT_INFO
|
||||
from audio_splitter.constants import CONTAINER_INFO, CODEC_INFO
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["formats"])
|
||||
|
||||
@@ -10,16 +11,9 @@ router = APIRouter(prefix="/api", tags=["formats"])
|
||||
@router.get("/formats")
|
||||
async def get_formats():
|
||||
"""
|
||||
Return the list of supported container formats with their properties.
|
||||
Return the list of supported containers and codecs.
|
||||
"""
|
||||
return {
|
||||
"formats": [
|
||||
{
|
||||
"name": name,
|
||||
"ffmpeg": info["ffmpeg"],
|
||||
"extension": info["ext"],
|
||||
"audio_only": info["audio_only"],
|
||||
}
|
||||
for name, info in FORMAT_INFO.items()
|
||||
]
|
||||
"containers": CONTAINER_INFO,
|
||||
"codecs": CODEC_INFO,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// web/frontend/src/api/client.ts
|
||||
|
||||
import axios from 'axios'
|
||||
import { TracklistEntry, SplitOptions, TaskStatus } from '../types'
|
||||
import { TracklistEntry, SplitOptions, TaskStatus, FormatsResponse } from '../types'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api',
|
||||
@@ -68,3 +70,9 @@ export const getRecommendedFormat = async (task_id: string): Promise<{ format: s
|
||||
const response = await api.get(`/info/recommended-format/${task_id}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// New: fetch containers and codecs
|
||||
export const getFormats = async (): Promise<FormatsResponse> => {
|
||||
const response = await api.get('/formats')
|
||||
return response.data
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ import { ExpandMore, ExpandLess } from '@mui/icons-material'
|
||||
import { useOptionsStore } from '../stores/optionsStore'
|
||||
import { useUploadStore } from '../stores/uploadStore'
|
||||
import { useValidationStore } from '../stores/validationStore'
|
||||
import { getFormats } from '../api/client'
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Section component (collapsible)
|
||||
// ------------------------------------------------------------------------------
|
||||
interface SectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
@@ -51,54 +55,70 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------
|
||||
// Main OptionsPanel component
|
||||
// ------------------------------------------------------------------------------
|
||||
export const OptionsPanel: React.FC = () => {
|
||||
const { options, setOptions } = useOptionsStore()
|
||||
const {
|
||||
options,
|
||||
setOptions,
|
||||
containers,
|
||||
codecs,
|
||||
setContainers,
|
||||
setCodecs,
|
||||
} = useOptionsStore()
|
||||
|
||||
const { hasVideo } = useUploadStore()
|
||||
const { formatError, setFormatError } = useValidationStore()
|
||||
|
||||
// Audio-only formats from backend constants (hardcoded for now)
|
||||
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac']
|
||||
// Fetch containers and codecs from backend on mount
|
||||
useEffect(() => {
|
||||
const fetchFormats = async () => {
|
||||
try {
|
||||
const data = await getFormats()
|
||||
setContainers(data.containers)
|
||||
setCodecs(data.codecs)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch formats:', err)
|
||||
}
|
||||
}
|
||||
fetchFormats()
|
||||
}, [setContainers, setCodecs])
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Handlers
|
||||
// --------------------------------------------------------------
|
||||
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) {
|
||||
}
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setOptions({ [field]: value })
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Format validation – re-run when relevant state changes
|
||||
// --------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
const selectedContainer = containers.find(c => c.name === options.format)
|
||||
if (selectedContainer?.audio_only && hasVideo && !options.drop_video) {
|
||||
setFormatError(
|
||||
`Format '${options.format}' does not support video streams. Please enable "Drop video" or choose a container that supports video (e.g., MKV, MP4).`
|
||||
`Container '${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])
|
||||
}, [containers, options.format, options.drop_video, hasVideo, setFormatError])
|
||||
|
||||
// --------------------------------------------------------------
|
||||
// Render
|
||||
// --------------------------------------------------------------
|
||||
return (
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
@@ -111,9 +131,12 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Output Settings */}
|
||||
{/* ------------------------------------------------------------------------
|
||||
Output Settings
|
||||
------------------------------------------------------------------------ */}
|
||||
<Section title="Output Settings" defaultExpanded>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Container dropdown */}
|
||||
<TextField
|
||||
label="Container"
|
||||
select
|
||||
@@ -126,18 +149,14 @@ export const OptionsPanel: React.FC = () => {
|
||||
formatError || "Determines the output file extension and container structure."
|
||||
}
|
||||
>
|
||||
<MenuItem value="mp3">MP3 (.mp3)</MenuItem>
|
||||
<MenuItem value="m4a">M4A (.m4a)</MenuItem>
|
||||
<MenuItem value="mkv">MKV (.mkv)</MenuItem>
|
||||
<MenuItem value="mp4">MP4 (.mp4)</MenuItem>
|
||||
<MenuItem value="ogg">OGG (.ogg)</MenuItem>
|
||||
<MenuItem value="opus">OPUS (.opus)</MenuItem>
|
||||
<MenuItem value="flac">FLAC (.flac)</MenuItem>
|
||||
<MenuItem value="wav">WAV (.wav)</MenuItem>
|
||||
<MenuItem value="aac">AAC (.aac)</MenuItem>
|
||||
{containers.map((container) => (
|
||||
<MenuItem key={container.name} value={container.name}>
|
||||
{container.name.toUpperCase()} ({container.extension})
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{/* Transcode option – remains unchanged but clearly labeled */}
|
||||
{/* Audio Codec (Transcode) dropdown */}
|
||||
<TextField
|
||||
label="Audio Codec (Transcode)"
|
||||
select
|
||||
@@ -148,11 +167,16 @@ export const OptionsPanel: React.FC = () => {
|
||||
helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original."
|
||||
>
|
||||
<MenuItem value="">Copy (no transcoding)</MenuItem>
|
||||
<MenuItem value="libmp3lame">MP3 (LAME)</MenuItem>
|
||||
<MenuItem value="aac">AAC</MenuItem>
|
||||
<MenuItem value="libopus">OPUS</MenuItem>
|
||||
{codecs
|
||||
.filter(c => c.supports_transcoding)
|
||||
.map((codec) => (
|
||||
<MenuItem key={codec.name} value={codec.ffmpeg}>
|
||||
{codec.name.toUpperCase()} (→ {codec.recommended_container})
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
{/* Drop video stream (only shown if video present) */}
|
||||
{hasVideo && (
|
||||
<FormControlLabel
|
||||
control={
|
||||
@@ -165,6 +189,7 @@ export const OptionsPanel: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Drop subtitle streams */}
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
@@ -177,7 +202,9 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Filename Settings */}
|
||||
{/* ------------------------------------------------------------------------
|
||||
Filename Settings
|
||||
------------------------------------------------------------------------ */}
|
||||
<Section title="Filename Settings">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
@@ -233,7 +260,9 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Metadata Settings */}
|
||||
{/* ------------------------------------------------------------------------
|
||||
Metadata Settings
|
||||
------------------------------------------------------------------------ */}
|
||||
<Section title="Metadata Settings">
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
@@ -289,7 +318,9 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Box>
|
||||
</Section>
|
||||
|
||||
{/* Tracklist Settings */}
|
||||
{/* ------------------------------------------------------------------------
|
||||
Tracklist Settings
|
||||
------------------------------------------------------------------------ */}
|
||||
<Section title="Tracklist Settings" defaultExpanded>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
@@ -304,9 +335,4 @@ export const OptionsPanel: React.FC = () => {
|
||||
</Section>
|
||||
</Paper>
|
||||
)
|
||||
|
||||
// Helper function for option updates
|
||||
function handleChange(field: string, value: any) {
|
||||
setOptions({ [field]: value })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// web/frontend/src/stores/optionsStore.ts
|
||||
|
||||
import { create } from 'zustand'
|
||||
import { SplitOptions } from '../types'
|
||||
import { SplitOptions, ContainerInfo, CodecInfo } from '../types'
|
||||
import {
|
||||
DEFAULT_FORMAT,
|
||||
DEFAULT_OUTPUT_TEMPLATE,
|
||||
@@ -21,6 +22,17 @@ import {
|
||||
DEFAULT_TRACKLIST_FORMAT,
|
||||
} from '../constants/generated'
|
||||
|
||||
// Extend the state
|
||||
interface OptionsState {
|
||||
options: SplitOptions
|
||||
containers: ContainerInfo[]
|
||||
codecs: CodecInfo[]
|
||||
setOptions: (options: Partial<SplitOptions>) => void
|
||||
setContainers: (containers: ContainerInfo[]) => void
|
||||
setCodecs: (codecs: CodecInfo[]) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: SplitOptions = {
|
||||
format: DEFAULT_FORMAT,
|
||||
transcode_to: DEFAULT_TRANSCODE_TO ?? '',
|
||||
@@ -41,17 +53,20 @@ const DEFAULT_OPTIONS: SplitOptions = {
|
||||
tracklist_format: DEFAULT_TRACKLIST_FORMAT,
|
||||
}
|
||||
|
||||
interface OptionsState {
|
||||
options: SplitOptions
|
||||
setOptions: (options: Partial<SplitOptions>) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useOptionsStore = create<OptionsState>((set) => ({
|
||||
options: { ...DEFAULT_OPTIONS },
|
||||
containers: [],
|
||||
codecs: [],
|
||||
setOptions: (newOptions) =>
|
||||
set((state) => ({
|
||||
options: { ...state.options, ...newOptions },
|
||||
})),
|
||||
reset: () => set({ options: { ...DEFAULT_OPTIONS } }),
|
||||
setContainers: (containers) => set({ containers }),
|
||||
setCodecs: (codecs) => set({ codecs }),
|
||||
reset: () =>
|
||||
set({
|
||||
options: { ...DEFAULT_OPTIONS },
|
||||
containers: [],
|
||||
codecs: [],
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -51,3 +51,22 @@ export interface SplitResponse {
|
||||
task_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
name: string
|
||||
ffmpeg: string
|
||||
extension: string
|
||||
audio_only: boolean
|
||||
}
|
||||
|
||||
export interface CodecInfo {
|
||||
name: string
|
||||
ffmpeg: string
|
||||
recommended_container: string
|
||||
supports_transcoding: boolean
|
||||
}
|
||||
|
||||
export interface FormatsResponse {
|
||||
containers: ContainerInfo[]
|
||||
codecs: CodecInfo[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user