fix (core): main fixes of codec/container/file_extension inconsistency #11

Merged
max merged 8 commits from major_fix into dev 2026-09-01 15:11:40 +04:00
7 changed files with 183 additions and 110 deletions
Showing only changes of commit 7af2600ed8 - Show all commits
+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 = { FORMAT_INFO = {
'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True}, 'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True},
'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', '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}, '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. # Container list with display names and extensions (for frontend)
DEFAULT_BAD_CHARS = r',!@#№$;:%^&?*(){}[]\/<>+=~`\' ' # ------------------------------------------------------------------------------
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.""" """Container format decision and validation."""
from typing import Dict, Optional 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]: def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]:
""" """
Given the container format and audio codec, determine the recommended output format. Given the container format and audio codec, determine the recommended output format.
This is used when the user has not explicitly specified a format. This uses the CODEC_TO_CONTAINER_MAP to map codec → container.
It prioritizes the codec to choose the most appropriate container/extension. If the codec is not found, it falls back to the container.
Args: Args:
container: Container name (e.g., 'ogg', 'mp4', 'matroska') as returned by get_container_format(). 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: if not container:
return None return None
# Codec-based decisions (highest priority) # Codec-based decision (highest priority)
if codec == 'opus': if codec and codec in CODEC_TO_CONTAINER_MAP:
return 'opus' return CODEC_TO_CONTAINER_MAP[codec]
if codec in ('aac', 'alac', 'he-aac'):
return 'm4a'
if codec == 'mp3':
return 'mp3'
if codec == 'vorbis':
return 'ogg'
if codec == 'flac':
return 'flac'
# Container-based fallback (lower priority) # Container-based fallback (lowest priority)
if container in ('mp4', 'm4a', 'mov', '3gp'): # Ensure the container is in FORMAT_INFO
return 'mp4' if container in FORMAT_INFO:
if container in ('matroska', 'webm'): return container
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'
return None return None
+5 -11
View File
@@ -1,8 +1,9 @@
# web/backend/api/formats.py
"""Endpoint to expose format information to the frontend.""" """Endpoint to expose format information to the frontend."""
from fastapi import APIRouter 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"]) router = APIRouter(prefix="/api", tags=["formats"])
@@ -10,16 +11,9 @@ router = APIRouter(prefix="/api", tags=["formats"])
@router.get("/formats") @router.get("/formats")
async def 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 { return {
"formats": [ "containers": CONTAINER_INFO,
{ "codecs": CODEC_INFO,
"name": name,
"ffmpeg": info["ffmpeg"],
"extension": info["ext"],
"audio_only": info["audio_only"],
}
for name, info in FORMAT_INFO.items()
]
} }
+9 -1
View File
@@ -1,5 +1,7 @@
// web/frontend/src/api/client.ts
import axios from 'axios' import axios from 'axios'
import { TracklistEntry, SplitOptions, TaskStatus } from '../types' import { TracklistEntry, SplitOptions, TaskStatus, FormatsResponse } from '../types'
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', 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}`) const response = await api.get(`/info/recommended-format/${task_id}`)
return response.data 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 { useOptionsStore } from '../stores/optionsStore'
import { useUploadStore } from '../stores/uploadStore' import { useUploadStore } from '../stores/uploadStore'
import { useValidationStore } from '../stores/validationStore' import { useValidationStore } from '../stores/validationStore'
import { getFormats } from '../api/client'
// ------------------------------------------------------------------------------
// Section component (collapsible)
// ------------------------------------------------------------------------------
interface SectionProps { interface SectionProps {
title: string title: string
children: React.ReactNode children: React.ReactNode
@@ -51,54 +55,70 @@ const Section: React.FC<SectionProps> = ({ title, children, defaultExpanded = fa
) )
} }
// ------------------------------------------------------------------------------
// Main OptionsPanel component
// ------------------------------------------------------------------------------
export const OptionsPanel: React.FC = () => { export const OptionsPanel: React.FC = () => {
const { options, setOptions } = useOptionsStore() const {
options,
setOptions,
containers,
codecs,
setContainers,
setCodecs,
} = useOptionsStore()
const { hasVideo } = useUploadStore() const { hasVideo } = useUploadStore()
const { formatError, setFormatError } = useValidationStore() const { formatError, setFormatError } = useValidationStore()
// Audio-only formats from backend constants (hardcoded for now) // Fetch containers and codecs from backend on mount
const audioOnlyFormats = ['mp3', 'm4a', 'ogg', 'opus', 'flac', 'wav', 'aac'] 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 handleFormatChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFormat = e.target.value const newFormat = e.target.value
setOptions({ format: newFormat }) 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 handleDropVideoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const checked = e.target.checked const checked = e.target.checked
setOptions({ drop_video: 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( 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 { } else {
setFormatError(null) setFormatError(null)
} }
} }, [containers, options.format, options.drop_video, hasVideo, setFormatError])
// 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])
// --------------------------------------------------------------
// Render
// --------------------------------------------------------------
return ( return (
<Paper sx={{ p: 3 }}> <Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}> <Typography variant="h6" sx={{ mb: 2 }}>
@@ -111,9 +131,12 @@ export const OptionsPanel: React.FC = () => {
</Alert> </Alert>
)} )}
{/* Output Settings */} {/* ------------------------------------------------------------------------
Output Settings
------------------------------------------------------------------------ */}
<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 }}>
{/* Container dropdown */}
<TextField <TextField
label="Container" label="Container"
select select
@@ -126,18 +149,14 @@ export const OptionsPanel: React.FC = () => {
formatError || "Determines the output file extension and container structure." formatError || "Determines the output file extension and container structure."
} }
> >
<MenuItem value="mp3">MP3 (.mp3)</MenuItem> {containers.map((container) => (
<MenuItem value="m4a">M4A (.m4a)</MenuItem> <MenuItem key={container.name} value={container.name}>
<MenuItem value="mkv">MKV (.mkv)</MenuItem> {container.name.toUpperCase()} ({container.extension})
<MenuItem value="mp4">MP4 (.mp4)</MenuItem> </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>
</TextField> </TextField>
{/* Transcode option remains unchanged but clearly labeled */} {/* Audio Codec (Transcode) dropdown */}
<TextField <TextField
label="Audio Codec (Transcode)" label="Audio Codec (Transcode)"
select 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." 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> {codecs
<MenuItem value="aac">AAC</MenuItem> .filter(c => c.supports_transcoding)
<MenuItem value="libopus">OPUS</MenuItem> .map((codec) => (
<MenuItem key={codec.name} value={codec.ffmpeg}>
{codec.name.toUpperCase()} ( {codec.recommended_container})
</MenuItem>
))}
</TextField> </TextField>
{/* Drop video stream (only shown if video present) */}
{hasVideo && ( {hasVideo && (
<FormControlLabel <FormControlLabel
control={ control={
@@ -165,6 +189,7 @@ export const OptionsPanel: React.FC = () => {
/> />
)} )}
{/* Drop subtitle streams */}
<FormControlLabel <FormControlLabel
control={ control={
<Switch <Switch
@@ -177,7 +202,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Filename Settings */} {/* ------------------------------------------------------------------------
Filename Settings
------------------------------------------------------------------------ */}
<Section title="Filename Settings"> <Section title="Filename Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -233,7 +260,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Metadata Settings */} {/* ------------------------------------------------------------------------
Metadata Settings
------------------------------------------------------------------------ */}
<Section title="Metadata Settings"> <Section title="Metadata Settings">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -289,7 +318,9 @@ export const OptionsPanel: React.FC = () => {
</Box> </Box>
</Section> </Section>
{/* Tracklist Settings */} {/* ------------------------------------------------------------------------
Tracklist Settings
------------------------------------------------------------------------ */}
<Section title="Tracklist Settings" defaultExpanded> <Section title="Tracklist Settings" defaultExpanded>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField <TextField
@@ -304,9 +335,4 @@ export const OptionsPanel: React.FC = () => {
</Section> </Section>
</Paper> </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 // web/frontend/src/stores/optionsStore.ts
import { create } from 'zustand' import { create } from 'zustand'
import { SplitOptions } from '../types' import { SplitOptions, ContainerInfo, CodecInfo } from '../types'
import { import {
DEFAULT_FORMAT, DEFAULT_FORMAT,
DEFAULT_OUTPUT_TEMPLATE, DEFAULT_OUTPUT_TEMPLATE,
@@ -21,6 +22,17 @@ import {
DEFAULT_TRACKLIST_FORMAT, DEFAULT_TRACKLIST_FORMAT,
} from '../constants/generated' } 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 = { const DEFAULT_OPTIONS: SplitOptions = {
format: DEFAULT_FORMAT, format: DEFAULT_FORMAT,
transcode_to: DEFAULT_TRANSCODE_TO ?? '', transcode_to: DEFAULT_TRANSCODE_TO ?? '',
@@ -41,17 +53,20 @@ const DEFAULT_OPTIONS: SplitOptions = {
tracklist_format: DEFAULT_TRACKLIST_FORMAT, tracklist_format: DEFAULT_TRACKLIST_FORMAT,
} }
interface OptionsState {
options: SplitOptions
setOptions: (options: Partial<SplitOptions>) => void
reset: () => void
}
export const useOptionsStore = create<OptionsState>((set) => ({ export const useOptionsStore = create<OptionsState>((set) => ({
options: { ...DEFAULT_OPTIONS }, options: { ...DEFAULT_OPTIONS },
containers: [],
codecs: [],
setOptions: (newOptions) => setOptions: (newOptions) =>
set((state) => ({ set((state) => ({
options: { ...state.options, ...newOptions }, 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 task_id: string
status: 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[]
}