diff --git a/audio_splitter/constants.py b/audio_splitter/constants.py index f608d35..da040dd 100644 --- a/audio_splitter/constants.py +++ b/audio_splitter/constants.py @@ -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'!@#№$;:%^&?*(){}[]\/<>+=~`\' ' diff --git a/audio_splitter/formats.py b/audio_splitter/formats.py index 6aaae08..84b2d56 100644 --- a/audio_splitter/formats.py +++ b/audio_splitter/formats.py @@ -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 diff --git a/web/backend/api/formats.py b/web/backend/api/formats.py index 0c0a995..6c88699 100644 --- a/web/backend/api/formats.py +++ b/web/backend/api/formats.py @@ -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, } diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts index 8677837..546044f 100644 --- a/web/frontend/src/api/client.ts +++ b/web/frontend/src/api/client.ts @@ -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 => { + const response = await api.get('/formats') + return response.data +} diff --git a/web/frontend/src/components/OptionsPanel.tsx b/web/frontend/src/components/OptionsPanel.tsx index 0fd3ef8..dbd5aaf 100644 --- a/web/frontend/src/components/OptionsPanel.tsx +++ b/web/frontend/src/components/OptionsPanel.tsx @@ -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 = ({ 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) => { 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) => { 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 ( @@ -111,9 +131,12 @@ export const OptionsPanel: React.FC = () => { )} - {/* Output Settings */} + {/* ------------------------------------------------------------------------ + Output Settings + ------------------------------------------------------------------------ */}
+ {/* Container dropdown */} { formatError || "Determines the output file extension and container structure." } > - MP3 (.mp3) - M4A (.m4a) - MKV (.mkv) - MP4 (.mp4) - OGG (.ogg) - OPUS (.opus) - FLAC (.flac) - WAV (.wav) - AAC (.aac) + {containers.map((container) => ( + + {container.name.toUpperCase()} ({container.extension}) + + ))} - {/* Transcode option – remains unchanged but clearly labeled */} + {/* Audio Codec (Transcode) dropdown */} { helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original." > Copy (no transcoding) - MP3 (LAME) - AAC - OPUS + {codecs + .filter(c => c.supports_transcoding) + .map((codec) => ( + + {codec.name.toUpperCase()} (→ {codec.recommended_container}) + + ))} + {/* Drop video stream (only shown if video present) */} {hasVideo && ( { /> )} + {/* Drop subtitle streams */} {
- {/* Filename Settings */} + {/* ------------------------------------------------------------------------ + Filename Settings + ------------------------------------------------------------------------ */}
{
- {/* Metadata Settings */} + {/* ------------------------------------------------------------------------ + Metadata Settings + ------------------------------------------------------------------------ */}
{
- {/* Tracklist Settings */} + {/* ------------------------------------------------------------------------ + Tracklist Settings + ------------------------------------------------------------------------ */}
{
) - - // Helper function for option updates - function handleChange(field: string, value: any) { - setOptions({ [field]: value }) - } } diff --git a/web/frontend/src/stores/optionsStore.ts b/web/frontend/src/stores/optionsStore.ts index ce2970b..420948a 100644 --- a/web/frontend/src/stores/optionsStore.ts +++ b/web/frontend/src/stores/optionsStore.ts @@ -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) => 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) => void - reset: () => void -} - export const useOptionsStore = create((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: [], + }), })) diff --git a/web/frontend/src/types/index.ts b/web/frontend/src/types/index.ts index 8605082..44a5b4b 100644 --- a/web/frontend/src/types/index.ts +++ b/web/frontend/src/types/index.ts @@ -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[] +}