fix (core): main fixes of codec/container/file_extension inconsistency #11
@@ -1,7 +1,9 @@
|
||||
"""Global constants and default values."""
|
||||
# audio_splitter/constants.py
|
||||
"""Global constants for the audio splitter."""
|
||||
|
||||
# Mapping from user‑friendly format names to FFmpeg format identifiers,
|
||||
# file extensions, and whether the container is audio‑only.
|
||||
# ------------------------------------------------------------------------------
|
||||
# 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
@@ -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
|
||||
|
||||
|
||||
@@ -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