FEATURE: all versions use single source of truth for codec/container/file_extension info

This commit is contained in:
2026-08-21 14:58:55 +05:00
parent d08045bf02
commit 7af2600ed8
7 changed files with 183 additions and 110 deletions
+39 -6
View File
@@ -1,7 +1,9 @@
"""Global constants and default values."""
# audio_splitter/constants.py
"""Global constants for the audio splitter."""
# Mapping from userfriendly format names to FFmpeg format identifiers,
# file extensions, and whether the container is audioonly.
# ------------------------------------------------------------------------------
# Container information (used for output format selection)
# ------------------------------------------------------------------------------
FORMAT_INFO = {
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True},
@@ -15,6 +17,37 @@ FORMAT_INFO = {
'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True},
}
# Default characters to replace when --replace-bad-chars is enabled.
# Includes common punctuation, single quote, and a trailing space.
DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' '
# ------------------------------------------------------------------------------
# Container list with display names and extensions (for frontend)
# ------------------------------------------------------------------------------
CONTAINER_INFO = [
{'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True},
{'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True},
{'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False},
{'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False},
{'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': True},
{'name': 'opus', 'ffmpeg': 'ogg', 'extension': '.opus', 'audio_only': True},
{'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True},
{'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True},
{'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True},
]
# ------------------------------------------------------------------------------
# Codec information (used for transcoding options)
# ------------------------------------------------------------------------------
CODEC_INFO = [
# codec name, ffmpeg encoder name, recommended container, supports transcoding
{'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'supports_transcoding': True},
{'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'm4a', 'supports_transcoding': True},
{'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'opus', 'supports_transcoding': True},
{'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'supports_transcoding': True},
{'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'supports_transcoding': True},
{'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'm4a', 'supports_transcoding': True},
{'name': 'pcm_s16le','ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'supports_transcoding': True},
]
# Map codec name → recommended container
CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO}
# Default bad characters (unchanged)
DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '
+11 -33
View File
@@ -1,16 +1,17 @@
# audio_splitter/formats.py
"""Container format decision and validation."""
from typing import Dict, Optional
from .constants import FORMAT_INFO
from .constants import FORMAT_INFO, CODEC_TO_CONTAINER_MAP, CONTAINER_INFO
def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
"""
Given the container format and audio codec, determine the recommended output format.
This is used when the user has not explicitly specified a format.
It prioritizes the codec to choose the most appropriate container/extension.
This uses the CODEC_TO_CONTAINER_MAP to map codec → container.
If the codec is not found, it falls back to the container.
Args:
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format().
@@ -22,37 +23,14 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) ->
if not container:
return None
# Codec-based decisions (highest priority)
if codec == 'opus':
return 'opus'
if codec in ('aac', 'alac', 'he-aac'):
return 'm4a'
if codec == 'mp3':
return 'mp3'
if codec == 'vorbis':
return 'ogg'
if codec == 'flac':
return 'flac'
# Codec-based decision (highest priority)
if codec and codec in CODEC_TO_CONTAINER_MAP:
return CODEC_TO_CONTAINER_MAP[codec]
# Container-based fallback (lower priority)
if container in ('mp4', 'm4a', 'mov', '3gp'):
return 'mp4'
if container in ('matroska', 'webm'):
return 'matroska'
if container in ('ogg',):
return 'ogg'
if container in ('mp3', 'mpeg'):
return 'mp3'
if container == 'flac':
return 'flac'
if container == 'wav':
return 'wav'
if container == 'aac':
return 'aac'
if container == 'opus':
return 'opus'
if container == 'amr':
return 'amr'
# Container-based fallback (lowest priority)
# Ensure the container is in FORMAT_INFO
if container in FORMAT_INFO:
return container
return None
+5 -11
View File
@@ -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,
}
+9 -1
View File
@@ -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
}
+77 -51
View File
@@ -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 })
}
}
+23 -8
View File
@@ -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: [],
}),
}))
+19
View File
@@ -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[]
}