From 7af2600ed861759021f249781736b81efffc0d8f Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Fri, 21 Aug 2026 14:58:55 +0500 Subject: [PATCH 1/8] FEATURE: all versions use single source of truth for codec/container/file_extension info --- audio_splitter/constants.py | 45 ++++++- audio_splitter/formats.py | 44 ++----- web/backend/api/formats.py | 16 +-- web/frontend/src/api/client.ts | 10 +- web/frontend/src/components/OptionsPanel.tsx | 128 +++++++++++-------- web/frontend/src/stores/optionsStore.ts | 31 +++-- web/frontend/src/types/index.ts | 19 +++ 7 files changed, 183 insertions(+), 110 deletions(-) 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[] +} -- 2.52.0 From 262d458ce45b84cd7da49f4431a10ff8d596c0a8 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Fri, 21 Aug 2026 15:59:05 +0500 Subject: [PATCH 2/8] FEATURE: codecs/containers/file_extensions inconsistency fixed --- audio_splitter/constants.py | 145 +++++++++++++++++++++++++++--------- audio_splitter/core.py | 2 +- audio_splitter/ffmpeg.py | 21 ++++++ audio_splitter/formats.py | 132 +++++++++++++++++++------------- 4 files changed, 209 insertions(+), 91 deletions(-) diff --git a/audio_splitter/constants.py b/audio_splitter/constants.py index da040dd..b49765f 100644 --- a/audio_splitter/constants.py +++ b/audio_splitter/constants.py @@ -1,53 +1,124 @@ # audio_splitter/constants.py -"""Global constants for the audio splitter.""" +"""Global constants for the audio splitter. + +This file serves as the single source of truth for: +- Container formats and their properties +- Audio codecs and their recommended containers +- File extensions for each codec/container combination + +Codec != Container != File Extension. +Example: Opus (codec) → Ogg (container) → .opus (extension) +""" # ------------------------------------------------------------------------------ -# Container information (used for output format selection) +# Container information # ------------------------------------------------------------------------------ +# Each container entry: +# - name: internal identifier used in the code +# - ffmpeg: name passed to FFmpeg's -f option +# - extension: default file extension +# - audio_only: whether the container supports video/subtitle streams +# - supports_video: whether video streams can be stored +# - supports_subs: whether subtitle streams can be stored +CONTAINER_INFO = [ + # Audio-only containers + {'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, + {'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, # MP4 container, .m4a extension for audio-only + {'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, + {'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, + {'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, + + # Containers that support video and subtitles + {'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, + {'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, + {'name': 'matroska', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, + {'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, # Ogg supports video (Theora, Dirac) and subtitles + {'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'audio_only': False, 'supports_video': True, 'supports_subs': False}, # WebM is a subset of Matroska +] + +# Legacy FORMAT_INFO for backward compatibility with existing code +# Maps container name → FFmpeg format name, extension, and audio_only flag FORMAT_INFO = { - 'mp3': {'ffmpeg': 'mp3', 'ext': '.mp3', 'audio_only': True}, - 'm4a': {'ffmpeg': 'mp4', 'ext': '.m4a', 'audio_only': True}, - 'mp4': {'ffmpeg': 'mp4', 'ext': '.mp4', 'audio_only': False}, - 'mkv': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False}, - 'matroska': {'ffmpeg': 'matroska', 'ext': '.mkv', 'audio_only': False}, - 'ogg': {'ffmpeg': 'ogg', 'ext': '.ogg', 'audio_only': True}, - 'opus': {'ffmpeg': 'ogg', 'ext': '.opus', 'audio_only': True}, - 'flac': {'ffmpeg': 'flac', 'ext': '.flac', 'audio_only': True}, - 'wav': {'ffmpeg': 'wav', 'ext': '.wav', 'audio_only': True}, - 'aac': {'ffmpeg': 'adts', 'ext': '.aac', 'audio_only': True}, + container['name']: { + 'ffmpeg': container['ffmpeg'], + 'ext': container['extension'], + 'audio_only': container['audio_only'], + } + for container in CONTAINER_INFO } # ------------------------------------------------------------------------------ -# 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 information # ------------------------------------------------------------------------------ +# Each codec entry: +# - name: codec name (used in code) +# - ffmpeg: encoder name passed to FFmpeg's -c:a option +# - recommended_container: the container format recommended for this codec +# - recommended_extension: the recommended file extension for this codec +# - supports_transcoding: whether this codec can be used as output via FFmpeg +# - supports_video: whether this codec is for video (True) or audio (False) 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}, + # Audio codecs + {'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'supports_transcoding': True, 'supports_video': False}, + {'name': 'vorbis', 'ffmpeg': 'libvorbis', 'recommended_container': 'ogg', 'recommended_extension': '.ogg', 'supports_transcoding': True, 'supports_video': False}, + {'name': 'flac', 'ffmpeg': 'flac', 'recommended_container': 'flac', 'recommended_extension': '.flac', 'supports_transcoding': True, 'supports_video': False}, + {'name': 'aac', 'ffmpeg': 'aac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False}, + {'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'supports_transcoding': True, 'supports_video': False}, + {'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False}, + {'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'supports_transcoding': True, 'supports_video': False}, + + # Video codecs (for reference, not used for transcoding selection in the UI) + {'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'supports_transcoding': False, 'supports_video': True}, + {'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': False, 'supports_video': True}, + {'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': False, 'supports_video': True}, + {'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'supports_transcoding': False, 'supports_video': True}, + {'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': False, 'supports_video': True}, + {'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': False, 'supports_video': True}, ] -# Map codec name → recommended container +# ------------------------------------------------------------------------------ +# Video codec support per container +# ------------------------------------------------------------------------------ +# For each container, list of video codecs it supports. +# Use '*' to indicate that the container supports all video codecs (e.g., Matroska). +CONTAINER_VIDEO_CODEC_SUPPORT = { + 'mp4': ['h264', 'h265', 'vp9', 'av1', 'mpeg4', 'hevc'], # MP4 supports these via ISO BMFF + 'mkv': ['*'], # Matroska supports virtually all video codecs + 'matroska': ['*'], + 'ogg': ['theora', 'dirac', 'vp8'], # Ogg supports Theora, Dirac, VP8 + 'webm': ['vp8', 'vp9', 'av1'], # WebM is a subset of Matroska with VP8/VP9/AV1 + 'mp3': [], # Audio-only, no video support + 'm4a': [], # Audio-only, no video support + 'flac': [], # Audio-only + 'wav': [], # Audio-only + 'aac': [], # Audio-only +} + +# Map codec name to its type (audio/video) +# This is used to determine if a stream is audio or video +CODEC_TYPE_MAP = { + 'opus': 'audio', + 'vorbis': 'audio', + 'flac': 'audio', + 'aac': 'audio', + 'mp3': 'audio', + 'alac': 'audio', + 'pcm_s16le': 'audio', + 'theora': 'video', + 'vp8': 'video', + 'vp9': 'video', + 'av1': 'video', + 'h264': 'video', + 'h265': 'video', + 'png': 'video', # PNG is often used as attached picture (cover art) + 'mjpeg': 'video', # MJPEG also used for attached pictures +} + +# Map codec name → recommended container name CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO} +# Map codec name → recommended file extension +CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO} + # Default bad characters (unchanged) DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ' diff --git a/audio_splitter/core.py b/audio_splitter/core.py index de5b51e..c3b4b94 100644 --- a/audio_splitter/core.py +++ b/audio_splitter/core.py @@ -48,7 +48,7 @@ def split_audio(input_file, output_directory, tracks, args): print(f"Output container: {output_format}") validate_format_compatibility(output_format, stream_info, - args.drop_video, args.drop_subs) + args.drop_video, args.drop_subs, input_file) # Determine file extension. extension_info = FORMAT_INFO.get(output_format, {}) diff --git a/audio_splitter/ffmpeg.py b/audio_splitter/ffmpeg.py index e6570ca..fab1d67 100644 --- a/audio_splitter/ffmpeg.py +++ b/audio_splitter/ffmpeg.py @@ -257,3 +257,24 @@ def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: cmd.extend(['-y', output_path]) return cmd + +def get_video_codec(input_file: str) -> Optional[str]: + """ + Return the codec name of the first video stream. + + Args: + input_file: Path to the media file. + + Returns: + Codec name as a lowercase string, or None if no video stream exists. + """ + cmd = [ + 'ffprobe', '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=codec_name', + '-of', 'default=noprint_wrappers=1:nokey=1', + input_file + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + codec = result.stdout.strip().lower() + return codec if codec else None diff --git a/audio_splitter/formats.py b/audio_splitter/formats.py index 84b2d56..87ff45e 100644 --- a/audio_splitter/formats.py +++ b/audio_splitter/formats.py @@ -3,8 +3,7 @@ from typing import Dict, Optional -from .constants import FORMAT_INFO, CODEC_TO_CONTAINER_MAP, CONTAINER_INFO - +from .constants import FORMAT_INFO, CODEC_TO_CONTAINER_MAP, CONTAINER_INFO, CONTAINER_VIDEO_CODEC_SUPPORT, CODEC_TYPE_MAP def determine_default_format(container: Optional[str], codec: Optional[str]) -> Optional[str]: """ @@ -35,60 +34,10 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) -> return None -def determine_output_format(stream_info: Dict, user_format: Optional[str], - transcode_audio: Optional[str], input_file: Optional[str] = None) -> str: - """ - Decide which container format to use. - - If user_format is provided, use it. - Else, try to detect the input file's container and codec, and use the recommended format. - If detection fails or format is not supported, fallback to: - - MKV if video/subtitles exist - - MP3 if the audio codec is MP3 - - MP4 (M4A) otherwise - - Args: - stream_info: Dict from get_stream_info(). - user_format: User‑requested format (or None). - transcode_audio: Audio codec to transcode to (or None) (unused in this function). - input_file: Path to the input file (optional, used to detect container and codec). - - Returns: - A format name that exists in FORMAT_INFO. - """ - if user_format: - return user_format - - # If input_file is provided, try to detect container and codec - if input_file: - try: - from .ffmpeg import get_container_format, get_audio_codec - container = get_container_format(input_file) - codec = get_audio_codec(input_file) - fmt = determine_default_format(container, codec) - if fmt in FORMAT_INFO: - return fmt - except Exception: - # If detection fails, fall through to legacy logic - pass - - # Fallback: legacy behavior - if stream_info.get('has_video') or stream_info.get('has_subtitle'): - return 'matroska' - audio_codec = stream_info.get('audio_codec', '') - if audio_codec == 'mp3': - return 'mp3' - else: - return 'mp4' # .m4a - - def validate_format_compatibility(format_name: str, stream_info: Dict, - drop_video: bool, drop_subs: bool) -> None: + drop_video: bool, drop_subs: bool, input_file: Optional[str] = None) -> None: """ Ensure the chosen container can accommodate the streams we intend to keep. - - Raises: - ValueError: If the format is incompatible with the intended streams. """ info = FORMAT_INFO.get(format_name) if not info: @@ -106,3 +55,80 @@ def validate_format_compatibility(format_name: str, stream_info: Dict, f"Format '{format_name}' does not support subtitle streams. " "Please use --drop-subs or choose a container that supports subtitles." ) + else: + # If video is present and not dropped, check if the container supports the video codec + if stream_info.get('has_video') and not drop_video and input_file: + from .ffmpeg import get_video_codec + video_codec = get_video_codec(input_file) + if video_codec and not is_video_codec_supported(format_name, video_codec): + raise ValueError( + f"Container '{format_name}' does not support video codec '{video_codec}'. " + "Please use --drop-video or choose a container that supports this video codec (e.g., MKV)." + ) + +def is_video_codec_supported(container_name: str, video_codec: str) -> bool: + """ + Check if a given container supports a specific video codec. + + Args: + container_name: Name of the container (e.g., 'mkv', 'ogg') + video_codec: Video codec name (e.g., 'theora', 'h264', 'png') + + Returns: + True if supported, False otherwise. + """ + support = CONTAINER_VIDEO_CODEC_SUPPORT.get(container_name, []) + if not support: + return False + if '*' in support: + return True + return video_codec in support + + +def determine_output_format(stream_info: Dict, user_format: Optional[str], + transcode_audio: Optional[str], input_file: Optional[str] = None) -> str: + """ + Decide which container format to use. + + If user_format is provided, use it. + Else, try to detect the input file's container and codec, and use the recommended format. + If detection fails or format is not supported, fallback to: + - MKV if video/subtitles exist and the video codec is not supported by the recommended container + - MP3 if the audio codec is MP3 + - MP4 (M4A) otherwise + """ + if user_format: + return user_format + + # Try to detect container and codec from input file + if input_file: + try: + from .ffmpeg import get_container_format, get_audio_codec, get_video_codec + container = get_container_format(input_file) + audio_codec = get_audio_codec(input_file) + video_codec = get_video_codec(input_file) # NEW: get video codec + + # Determine recommended format based on audio codec + fmt = determine_default_format(container, audio_codec) + + # If video is present and not dropped, check if the recommended container supports the video codec + if stream_info.get('has_video') and not transcode_audio: # transcode_audio doesn't affect video + # If the recommended container doesn't support the video codec, fallback to MKV + if fmt and not is_video_codec_supported(fmt, video_codec): + fmt = 'matroska' # MKV supports virtually all video codecs + # Optionally log a warning + # print(f"Warning: Container '{fmt}' does not support video codec '{video_codec}'. Falling back to MKV.") + + if fmt in FORMAT_INFO: + return fmt + except Exception: + pass + + # Fallback: legacy behavior + if stream_info.get('has_video') or stream_info.get('has_subtitle'): + return 'matroska' + audio_codec = stream_info.get('audio_codec', '') + if audio_codec == 'mp3': + return 'mp3' + else: + return 'mp4' # .m4a -- 2.52.0 From 690c5f98c6aec4374da2cfce1bed35bcebaa4136 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Tue, 25 Aug 2026 10:40:48 +0500 Subject: [PATCH 3/8] FEATURE: adds strict codec/container/file_extension verification, renames ambiguous options, adds flexible transcoding --- audio_splitter/constants.py | 148 +++++++++++---------- audio_splitter/core.py | 146 +++++++++++++++++---- audio_splitter/ffmpeg.py | 254 ++++++++++++++++++++++++------------ audio_splitter/formats.py | 141 ++++++++++---------- audio_splitter/main.py | 124 ++++++++++-------- 5 files changed, 506 insertions(+), 307 deletions(-) diff --git a/audio_splitter/constants.py b/audio_splitter/constants.py index b49765f..941c788 100644 --- a/audio_splitter/constants.py +++ b/audio_splitter/constants.py @@ -4,7 +4,9 @@ This file serves as the single source of truth for: - Container formats and their properties - Audio codecs and their recommended containers +- Video codec support per container - File extensions for each codec/container combination +- Compatibility matrix for codec/container validation Codec != Container != File Extension. Example: Opus (codec) → Ogg (container) → .opus (extension) @@ -13,31 +15,22 @@ Example: Opus (codec) → Ogg (container) → .opus (extension) # ------------------------------------------------------------------------------ # Container information # ------------------------------------------------------------------------------ -# Each container entry: -# - name: internal identifier used in the code -# - ffmpeg: name passed to FFmpeg's -f option -# - extension: default file extension -# - audio_only: whether the container supports video/subtitle streams -# - supports_video: whether video streams can be stored -# - supports_subs: whether subtitle streams can be stored CONTAINER_INFO = [ # Audio-only containers {'name': 'mp3', 'ffmpeg': 'mp3', 'extension': '.mp3', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, - {'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, # MP4 container, .m4a extension for audio-only + {'name': 'm4a', 'ffmpeg': 'mp4', 'extension': '.m4a', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, {'name': 'flac', 'ffmpeg': 'flac', 'extension': '.flac', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, {'name': 'wav', 'ffmpeg': 'wav', 'extension': '.wav', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, {'name': 'aac', 'ffmpeg': 'adts', 'extension': '.aac', 'audio_only': True, 'supports_video': False, 'supports_subs': False}, - # Containers that support video and subtitles {'name': 'mp4', 'ffmpeg': 'mp4', 'extension': '.mp4', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, {'name': 'mkv', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, {'name': 'matroska', 'ffmpeg': 'matroska', 'extension': '.mkv', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, - {'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, # Ogg supports video (Theora, Dirac) and subtitles - {'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'audio_only': False, 'supports_video': True, 'supports_subs': False}, # WebM is a subset of Matroska + {'name': 'ogg', 'ffmpeg': 'ogg', 'extension': '.ogg', 'audio_only': False, 'supports_video': True, 'supports_subs': True}, + {'name': 'webm', 'ffmpeg': 'webm', 'extension': '.webm', 'audio_only': False, 'supports_video': True, 'supports_subs': False}, ] -# Legacy FORMAT_INFO for backward compatibility with existing code -# Maps container name → FFmpeg format name, extension, and audio_only flag +# Legacy FORMAT_INFO for backward compatibility FORMAT_INFO = { container['name']: { 'ffmpeg': container['ffmpeg'], @@ -50,13 +43,6 @@ FORMAT_INFO = { # ------------------------------------------------------------------------------ # Codec information # ------------------------------------------------------------------------------ -# Each codec entry: -# - name: codec name (used in code) -# - ffmpeg: encoder name passed to FFmpeg's -c:a option -# - recommended_container: the container format recommended for this codec -# - recommended_extension: the recommended file extension for this codec -# - supports_transcoding: whether this codec can be used as output via FFmpeg -# - supports_video: whether this codec is for video (True) or audio (False) CODEC_INFO = [ # Audio codecs {'name': 'opus', 'ffmpeg': 'libopus', 'recommended_container': 'ogg', 'recommended_extension': '.opus', 'supports_transcoding': True, 'supports_video': False}, @@ -66,59 +52,81 @@ CODEC_INFO = [ {'name': 'mp3', 'ffmpeg': 'libmp3lame', 'recommended_container': 'mp3', 'recommended_extension': '.mp3', 'supports_transcoding': True, 'supports_video': False}, {'name': 'alac', 'ffmpeg': 'alac', 'recommended_container': 'mp4', 'recommended_extension': '.m4a', 'supports_transcoding': True, 'supports_video': False}, {'name': 'pcm_s16le', 'ffmpeg': 'pcm_s16le', 'recommended_container': 'wav', 'recommended_extension': '.wav', 'supports_transcoding': True, 'supports_video': False}, - - # Video codecs (for reference, not used for transcoding selection in the UI) - {'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'supports_transcoding': False, 'supports_video': True}, - {'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': False, 'supports_video': True}, - {'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': False, 'supports_video': True}, - {'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'supports_transcoding': False, 'supports_video': True}, - {'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': False, 'supports_video': True}, - {'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': False, 'supports_video': True}, + # Video codecs + {'name': 'theora', 'ffmpeg': 'libtheora', 'recommended_container': 'ogg', 'recommended_extension': '.ogv', 'supports_transcoding': True, 'supports_video': True}, + {'name': 'vp8', 'ffmpeg': 'libvpx', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True}, + {'name': 'vp9', 'ffmpeg': 'libvpx-vp9', 'recommended_container': 'webm', 'recommended_extension': '.webm', 'supports_transcoding': True, 'supports_video': True}, + {'name': 'av1', 'ffmpeg': 'libaom-av1', 'recommended_container': 'mkv', 'recommended_extension': '.mkv', 'supports_transcoding': True, 'supports_video': True}, + {'name': 'h264', 'ffmpeg': 'libx264', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True}, + {'name': 'h265', 'ffmpeg': 'libx265', 'recommended_container': 'mp4', 'recommended_extension': '.mp4', 'supports_transcoding': True, 'supports_video': True}, + {'name': 'png', 'ffmpeg': 'png', 'recommended_container': 'ogg', 'recommended_extension': '.png', 'supports_transcoding': False, 'supports_video': True}, + {'name': 'mjpeg', 'ffmpeg': 'mjpeg', 'recommended_container': 'ogg', 'recommended_extension': '.jpg', 'supports_transcoding': False, 'supports_video': True}, ] -# ------------------------------------------------------------------------------ -# Video codec support per container -# ------------------------------------------------------------------------------ -# For each container, list of video codecs it supports. -# Use '*' to indicate that the container supports all video codecs (e.g., Matroska). -CONTAINER_VIDEO_CODEC_SUPPORT = { - 'mp4': ['h264', 'h265', 'vp9', 'av1', 'mpeg4', 'hevc'], # MP4 supports these via ISO BMFF - 'mkv': ['*'], # Matroska supports virtually all video codecs - 'matroska': ['*'], - 'ogg': ['theora', 'dirac', 'vp8'], # Ogg supports Theora, Dirac, VP8 - 'webm': ['vp8', 'vp9', 'av1'], # WebM is a subset of Matroska with VP8/VP9/AV1 - 'mp3': [], # Audio-only, no video support - 'm4a': [], # Audio-only, no video support - 'flac': [], # Audio-only - 'wav': [], # Audio-only - 'aac': [], # Audio-only -} - -# Map codec name to its type (audio/video) -# This is used to determine if a stream is audio or video -CODEC_TYPE_MAP = { - 'opus': 'audio', - 'vorbis': 'audio', - 'flac': 'audio', - 'aac': 'audio', - 'mp3': 'audio', - 'alac': 'audio', - 'pcm_s16le': 'audio', - 'theora': 'video', - 'vp8': 'video', - 'vp9': 'video', - 'av1': 'video', - 'h264': 'video', - 'h265': 'video', - 'png': 'video', # PNG is often used as attached picture (cover art) - 'mjpeg': 'video', # MJPEG also used for attached pictures -} - -# Map codec name → recommended container name +# Map codec name → recommended container CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO} - -# Map codec name → recommended file extension CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO} -# Default bad characters (unchanged) +# ------------------------------------------------------------------------------ +# Video codec support per container (legacy, will be superseded by compatibility matrix) +# ------------------------------------------------------------------------------ +CONTAINER_VIDEO_CODEC_SUPPORT = { + 'mp4': ['h264', 'h265', 'vp9', 'av1', 'mpeg4', 'hevc'], + 'mkv': ['*'], + 'matroska': ['*'], + 'ogg': ['theora', 'dirac', 'vp8', 'png', 'mjpeg'], + 'webm': ['vp8', 'vp9', 'av1'], + 'mp3': [], + 'm4a': [], + 'flac': [], + 'wav': [], + 'aac': [], +} + +# ------------------------------------------------------------------------------ +# Compatibility matrix: container → supported audio and video codecs +# ------------------------------------------------------------------------------ +# Each container entry maps to a dict with 'audio' and 'video' keys. +# - 'audio': list of audio codec names that are supported in this container. +# Use None to indicate that any audio codec is supported. +# - 'video': list of video codec names that are supported in this container. +# Use None to indicate that any video codec is supported. +# Empty list means no video support (audio-only container). +COMPATIBILITY_MATRIX = { + 'mp3': {'audio': ['mp3'], 'video': []}, + 'm4a': {'audio': ['aac', 'alac', 'opus', 'flac'], 'video': []}, + 'mp4': {'audio': ['aac', 'alac', 'opus', 'flac', 'mp3'], 'video': ['h264', 'h265', 'vp9', 'av1']}, + 'mkv': {'audio': None, 'video': None}, + 'matroska': {'audio': None, 'video': None}, + 'ogg': {'audio': ['opus', 'vorbis', 'flac'], 'video': ['theora', 'vp8']}, + 'webm': {'audio': ['opus', 'vorbis'], 'video': ['vp8', 'vp9', 'av1']}, + 'flac': {'audio': ['flac'], 'video': []}, + 'wav': {'audio': ['pcm_s16le'], 'video': []}, + 'aac': {'audio': ['aac'], 'video': []}, +} + +# ------------------------------------------------------------------------------ +# Helper functions for compatibility checking +# ------------------------------------------------------------------------------ +def is_audio_codec_supported(container: str, audio_codec: str) -> bool: + """Check if an audio codec is supported in the given container.""" + entry = COMPATIBILITY_MATRIX.get(container, {}) + supported = entry.get('audio') + if supported is None: + return True + return audio_codec in supported + + +def is_video_codec_supported(container: str, video_codec: str) -> bool: + """Check if a video codec is supported in the given container.""" + entry = COMPATIBILITY_MATRIX.get(container, {}) + supported = entry.get('video') + if supported is None: + return True + return video_codec in supported + + +# ------------------------------------------------------------------------------ +# Default bad characters (for filename sanitization) +# ------------------------------------------------------------------------------ DEFAULT_BAD_CHARS = r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ' diff --git a/audio_splitter/core.py b/audio_splitter/core.py index c3b4b94..08a4a12 100644 --- a/audio_splitter/core.py +++ b/audio_splitter/core.py @@ -1,30 +1,63 @@ -"""Core logic: orchestrates the splitting process.""" +# audio_splitter/core.py +"""Core splitting logic – orchestrates the entire split process.""" import os import subprocess +import sys +from typing import List, Dict, Any from .constants import FORMAT_INFO -from .ffmpeg import get_audio_duration, get_stream_info, get_metadata, build_ffmpeg_command -from .formats import determine_output_format, validate_format_compatibility +from .ffmpeg import ( + get_audio_duration, + get_stream_info, + get_metadata, + build_ffmpeg_command, + is_attached_picture, + extract_cover_image, +) +from .formats import ( + determine_output_format, + validate_format_compatibility, +) from .timestamp import parse_track_timestamps, resolve_end_times from .filename import build_filename from .metadata import build_metadata_dict from .utils import format_time -def split_audio(input_file, output_directory, tracks, args): +def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, Any]], args) -> None: """ Main orchestration function: split the audio file into tracks. Args: input_file: Path to the input media file. output_directory: Directory where output files will be saved. - tracks: List of dicts, each containing parsed fields. - args: Parsed command‑line arguments (namespace). + tracks: List of dicts, each containing parsed fields (ts, tn, an, ...). + args: Parsed command‑line arguments (namespace) with attributes: + - container: output container name + - audio_codec: audio codec (copy or encoder) + - video_codec: video codec (copy or encoder) + - subtitle_codec: subtitle codec (copy or encoder) + - drop_video: bool + - drop_subs: bool + - number_tracks: bool + - output_template: str + - replace_bad_chars: bool + - replacement_char: str + - bad_chars: str + - skip_existing: bool + - album: str or None + - comment: str or None + - no_comment: bool + - comment_stream: int or None + - merge_comments: bool + - comment_separator: str + - delete_original: bool + - cover_image: str or None (optional, set by web backend) Raises: RuntimeError: If no audio stream is found. - ValueError: If timestamp parsing or format compatibility fails. + ValueError: If compatibility validation fails. """ # -------------------------------------------------------------------------- # 1. Setup @@ -42,26 +75,42 @@ def split_audio(input_file, output_directory, tracks, args): raise RuntimeError("No audio stream found in input file.") # -------------------------------------------------------------------------- - # 2. Format decision + # 2. Determine output container # -------------------------------------------------------------------------- - output_format = determine_output_format(stream_info, args.format, args.transcode_to, input_file=input_file) - print(f"Output container: {output_format}") - - validate_format_compatibility(output_format, stream_info, - args.drop_video, args.drop_subs, input_file) - - # Determine file extension. - extension_info = FORMAT_INFO.get(output_format, {}) - extension = extension_info.get('ext', '.mkv') + # Use args.container if provided, otherwise auto-detect + user_container = getattr(args, 'container', None) + output_container = determine_output_format( + stream_info, + user_format=user_container, + transcode_audio=args.audio_codec if args.audio_codec != 'copy' else None, + input_file=input_file + ) + print(f"Output container: {output_container}") # -------------------------------------------------------------------------- - # 3. Parse timestamps + # 3. Validate compatibility + # -------------------------------------------------------------------------- + try: + validate_format_compatibility( + container=output_container, + stream_info=stream_info, + drop_video=args.drop_video, + drop_subs=args.drop_subs, + input_file=input_file, + audio_codec=args.audio_codec, + video_codec=args.video_codec, + ) + except ValueError as e: + raise RuntimeError(f"Compatibility error: {e}") + + # -------------------------------------------------------------------------- + # 4. Parse timestamps # -------------------------------------------------------------------------- track_times = parse_track_timestamps(tracks) resolved_times = resolve_end_times(track_times, total_duration) # -------------------------------------------------------------------------- - # 4. Fetch original metadata (for fallbacks) + # 5. Fetch original metadata (for fallbacks) # -------------------------------------------------------------------------- input_metadata = get_metadata(input_file) original_album = input_metadata.get('album') @@ -78,7 +127,29 @@ def split_audio(input_file, output_directory, tracks, args): print(f" Stream {idx}: '{comment}'") # -------------------------------------------------------------------------- - # 5. Process each track + # 6. Handle attached picture (cover art) + # -------------------------------------------------------------------------- + cover_image_path = getattr(args, 'cover_image', None) + if cover_image_path and not os.path.exists(cover_image_path): + cover_image_path = None + + # If not provided via args (CLI case), try to detect and extract + if not cover_image_path and not args.drop_video and stream_info.get('has_video'): + if is_attached_picture(input_file): + cover_image_path = os.path.join(output_directory, 'cover.png') + if extract_cover_image(input_file, cover_image_path): + print("Extracted cover image for all tracks.") + else: + cover_image_path = None + + # -------------------------------------------------------------------------- + # 7. Determine file extension + # -------------------------------------------------------------------------- + extension_info = FORMAT_INFO.get(output_container, {}) + extension = extension_info.get('ext', '.mkv') + + # -------------------------------------------------------------------------- + # 8. Process each track # -------------------------------------------------------------------------- for idx, track in enumerate(tracks, start=1): start_seconds, end_seconds = resolved_times[idx - 1] @@ -91,33 +162,41 @@ def split_audio(input_file, output_directory, tracks, args): track_name = track.get('tn', 'Unknown') # ---------------------------------------------------------------------- - # 5a. Build filename + # 8a. Build filename # ---------------------------------------------------------------------- clean_filename = build_filename(track, idx, extension, args) output_path = os.path.join(output_directory, clean_filename) # ---------------------------------------------------------------------- - # 5b. Handle existing files + # 8b. Handle existing files # ---------------------------------------------------------------------- if args.skip_existing and os.path.exists(output_path): print(f"Skipping track {idx}: {output_path} already exists.") continue # ---------------------------------------------------------------------- - # 5c. Build metadata + # 8c. Build metadata # ---------------------------------------------------------------------- metadata = build_metadata_dict(track, idx, input_metadata, args) # ---------------------------------------------------------------------- - # 5d. Build and execute FFmpeg command + # 8d. Build and execute FFmpeg command # ---------------------------------------------------------------------- cmd = build_ffmpeg_command( - input_file, start_seconds, duration_seconds, output_path, - stream_info, output_format, args.transcode_to, - args.drop_video, args.drop_subs, - metadata=metadata + input_file=input_file, + start_seconds=start_seconds, + duration_seconds=duration_seconds, + output_path=output_path, + stream_info=stream_info, + format_opt=output_container, + audio_codec=args.audio_codec, + video_codec=args.video_codec, + subtitle_codec=args.subtitle_codec, + metadata=metadata, + cover_image_path=cover_image_path, ) + print(cmd) print(f"Extracting track {idx}: {track_name} " f"({format_time(start_seconds)} - {format_time(end_seconds)})") @@ -126,5 +205,16 @@ def split_audio(input_file, output_directory, tracks, args): if result.returncode != 0: print(f"ERROR extracting track {idx}:") print(result.stderr) + # Optionally stop on first error? We'll continue. else: print(f" -> Saved to: {output_path}") + + # -------------------------------------------------------------------------- + # 9. Delete original file if requested + # -------------------------------------------------------------------------- + if getattr(args, 'delete_original', False): + try: + os.remove(input_file) + print(f"Deleted original file: {input_file}") + except OSError as e: + print(f"Warning: Could not delete original file: {e}") diff --git a/audio_splitter/ffmpeg.py b/audio_splitter/ffmpeg.py index fab1d67..76b6463 100644 --- a/audio_splitter/ffmpeg.py +++ b/audio_splitter/ffmpeg.py @@ -1,4 +1,5 @@ -"""FFmpeg/FFprobe interaction utilities for the CLI and web backend.""" +# audio_splitter/ffmpeg.py +"""FFmpeg/FFprobe interaction utilities.""" import json import subprocess @@ -75,6 +76,28 @@ def get_audio_codec(input_file: str) -> Optional[str]: return codec if codec else None +def get_video_codec(input_file: str) -> Optional[str]: + """ + Return the codec name of the first video stream. + + Args: + input_file: Path to the media file. + + Returns: + Codec name as a lowercase string, or None if no video stream exists. + """ + cmd = [ + 'ffprobe', '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=codec_name', + '-of', 'default=noprint_wrappers=1:nokey=1', + input_file + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + codec = result.stdout.strip().lower() + return codec if codec else None + + def get_stream_info(input_file: str) -> Dict[str, any]: """ Collect information about the streams present in the input file. @@ -156,125 +179,194 @@ def get_container_format(input_file: str) -> Optional[str]: format_name = result.stdout.strip().split(',')[0] # take first if multiple if not format_name: return None - # Normalize common aliases to names used in FORMAT_INFO (container only, not codec-specific) - # This mapping is purely for container identification. + # Normalize common aliases to names used in FORMAT_INFO mapping = { - 'mpeg': 'mp3', # MPEG-1/2 audio (MP3) container + 'mpeg': 'mp3', 'mp2': 'mp3', 'mp4': 'mp4', - 'm4a': 'mp4', # M4A is MP4 container - 'mov': 'mp4', # QuickTime is MP4-like + 'm4a': 'mp4', + 'mov': 'mp4', '3gp': 'mp4', 'matroska': 'matroska', - 'webm': 'matroska', # WebM uses Matroska container + 'webm': 'matroska', 'ogg': 'ogg', 'flac': 'flac', 'wav': 'wav', 'aac': 'aac', 'opus': 'opus', 'mp3': 'mp3', - 'adts': 'aac', # raw AAC in ADTS container - 'amr': 'amr', # AMR container (rare) + 'adts': 'aac', + 'amr': 'amr', } return mapping.get(format_name, format_name) -def build_ffmpeg_command(input_file: str, start_seconds: int, duration_seconds: int, - output_path: str, stream_info: Dict, format_opt: Optional[str], - transcode_audio: Optional[str], drop_video: bool, drop_subs: bool, - metadata: Optional[Dict] = None) -> List[str]: +def is_attached_picture(input_file: str) -> bool: """ - Construct the FFmpeg command line as a list of arguments. + Check if the input file has a video stream that is an attached picture (cover art). - Args: - input_file: Path to the input media file. - start_seconds: Start time for the segment (in seconds). - duration_seconds: Duration of the segment (in seconds). - output_path: Destination path for the output file. - stream_info: Dictionary from get_stream_info(). - format_opt: Output container format (e.g., 'mp3'). - transcode_audio: Audio codec to transcode to (or None). - drop_video: True to remove video streams. - drop_subs: True to remove subtitle streams. - metadata: Optional dict of metadata key/value pairs to write. + Detection logic: + 1. If there is a video stream with disposition.attached_pic == 1, return True. + 2. Otherwise, if there is exactly one video stream and its codec is an image + format (PNG, MJPEG, JPEG, GIF, BMP), return True. Returns: - A list of command‑line arguments suitable for subprocess.run(). + True if a cover image is detected, False otherwise. """ cmd = [ - 'ffmpeg', - '-i', input_file, - '-ss', format_time(start_seconds), - '-t', format_time(duration_seconds) + 'ffprobe', '-v', 'quiet', + '-print_format', 'json', + '-select_streams', 'v', + '-show_entries', 'stream=codec_name,disposition', + input_file ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + return False - # Clear all original metadata. + try: + data = json.loads(result.stdout) + streams = data.get('streams', []) + if not streams: + return False + + # Check each stream + for stream in streams: + codec = stream.get('codec_name', '').lower() + disposition = stream.get('disposition', {}) + # If attached_pic is set, it's a cover image + if disposition.get('attached_pic') == 1: + return True + # If not, check if it's an image codec and we have exactly one video stream + if codec in ('png', 'mjpeg', 'jpeg', 'gif', 'bmp'): + # If there is exactly one video stream, treat it as cover + if len(streams) == 1: + return True + return False + except (json.JSONDecodeError, KeyError): + return False + + +def extract_cover_image(input_file: str, output_path: str) -> bool: + """ + Extract the first frame of the video stream (assumed to be an attached picture) + and save it to output_path. + + Returns: + True if extraction succeeded, False otherwise. + """ + cmd = [ + 'ffmpeg', '-i', input_file, + '-map', '0:v:0', + '-frames:v', '1', + '-y', + output_path + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + print(f"Failed to extract cover image: {result.stderr}") + return False + return True + + +def build_ffmpeg_command( + input_file: str, + start_seconds: int, + duration_seconds: int, + output_path: str, + stream_info: Dict, + format_opt: Optional[str], + audio_codec: Optional[str] = 'copy', + video_codec: Optional[str] = 'copy', + subtitle_codec: Optional[str] = 'copy', + metadata: Optional[Dict] = None, + cover_image_path: Optional[str] = None, +) -> List[str]: + """ + Construct the FFmpeg command line. + + Args: + audio_codec: 'copy' or encoder name (e.g., 'libopus') + video_codec: 'copy' or encoder name (e.g., 'libx264') + subtitle_codec: 'copy' or encoder name (e.g., 'srt') + """ + cmd = ['ffmpeg'] + + # Add cover image if provided + if cover_image_path: + cmd.extend(['-i', cover_image_path]) + cmd.extend(['-i', input_file]) + + # Time options + cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)]) + + # Clear metadata cmd.append('-map_metadata') cmd.append('-1') - # Apply custom metadata. + # Apply custom metadata if metadata: for key, value in metadata.items(): if value is not None and value != '': cmd.extend(['-metadata', f"{key}={value}"]) - # Stream mapping. - if drop_video and drop_subs: - cmd.extend(['-map', '0:a:0']) - elif drop_video: - cmd.extend(['-map', '0:a:0', '-map', '0:s?']) - elif drop_subs: - cmd.extend(['-map', '0:a:0', '-map', '0:v:0']) - else: - cmd.extend(['-map', '0']) + # ---------- Stream mapping and codecs ---------- + if cover_image_path: + # We have two inputs: index 0 = cover image, index 1 = main input + # Map audio from main input (index 1) and video from cover image (index 0) + cmd.extend(['-map', '1:a:0', '-map', '0:v:0']) - # Audio codec. - if transcode_audio: - cmd.extend(['-c:a', transcode_audio]) - if transcode_audio in ('libmp3lame', 'mp3'): - cmd.extend(['-b:a', '192k']) - elif transcode_audio in ('libopus', 'opus'): - cmd.extend(['-b:a', '128k']) - else: - cmd.extend(['-c:a', 'copy']) + # Video codec: use user-specified codec if provided, otherwise fallback to png + if video_codec and video_codec != 'copy': + cmd.extend(['-c:v', video_codec]) + else: + cmd.extend(['-c:v', 'png']) - # Video codec. - if not drop_video and stream_info['has_video']: - cmd.extend(['-c:v', 'copy']) - else: - cmd.append('-vn') + # Audio codec + if audio_codec and audio_codec != 'copy': + cmd.extend(['-c:a', audio_codec]) + else: + cmd.extend(['-c:a', 'copy']) - # Subtitle codec. - if not drop_subs and stream_info['has_subtitle']: - cmd.extend(['-c:s', 'copy']) - else: + # Subtitle: none (we don't copy from original when using cover image) cmd.append('-sn') - # Output format. + else: + # Standard mapping: copy all streams by default, then filter based on options + if not stream_info.get('has_video') or drop_video: + cmd.extend(['-map', '0:a:0']) + else: + # Map all streams + cmd.extend(['-map', '0']) + + # Audio codec + if audio_codec and audio_codec != 'copy': + cmd.extend(['-c:a', audio_codec]) + else: + cmd.extend(['-c:a', 'copy']) + + # Video codec (if video present and not dropped) + if stream_info.get('has_video') and not drop_video: + if video_codec and video_codec != 'copy': + cmd.extend(['-c:v', video_codec]) + else: + cmd.extend(['-c:v', 'copy']) + else: + cmd.append('-vn') + + # Subtitle codec + if stream_info.get('has_subtitle') and not drop_subs: + if subtitle_codec and subtitle_codec != 'copy': + cmd.extend(['-c:s', subtitle_codec]) + else: + cmd.extend(['-c:s', 'copy']) + else: + cmd.append('-sn') + + # Output format if format_opt: ffmpeg_format = FORMAT_INFO.get(format_opt, {}).get('ffmpeg', format_opt) cmd.extend(['-f', ffmpeg_format]) cmd.extend(['-y', output_path]) return cmd - -def get_video_codec(input_file: str) -> Optional[str]: - """ - Return the codec name of the first video stream. - - Args: - input_file: Path to the media file. - - Returns: - Codec name as a lowercase string, or None if no video stream exists. - """ - cmd = [ - 'ffprobe', '-v', 'error', - '-select_streams', 'v:0', - '-show_entries', 'stream=codec_name', - '-of', 'default=noprint_wrappers=1:nokey=1', - input_file - ] - result = subprocess.run(cmd, capture_output=True, text=True, check=False) - codec = result.stdout.strip().lower() - return codec if codec else None diff --git a/audio_splitter/formats.py b/audio_splitter/formats.py index 87ff45e..3af08d9 100644 --- a/audio_splitter/formats.py +++ b/audio_splitter/formats.py @@ -3,13 +3,22 @@ from typing import Dict, Optional -from .constants import FORMAT_INFO, CODEC_TO_CONTAINER_MAP, CONTAINER_INFO, CONTAINER_VIDEO_CODEC_SUPPORT, CODEC_TYPE_MAP +from .constants import ( + FORMAT_INFO, + CONTAINER_INFO, + CODEC_TO_CONTAINER_MAP, + COMPATIBILITY_MATRIX, + is_audio_codec_supported, + is_video_codec_supported, +) +from .defaults import DEFAULT_FORMAT + 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 uses the CODEC_TO_CONTAINER_MAP to map codec → container. + Uses the CODEC_TO_CONTAINER_MAP to map codec → container. If the codec is not found, it falls back to the container. Args: @@ -22,69 +31,17 @@ def determine_default_format(container: Optional[str], codec: Optional[str]) -> if not container: return None - # Codec-based decision (highest priority) if codec and codec in CODEC_TO_CONTAINER_MAP: - return CODEC_TO_CONTAINER_MAP[codec] + fmt = CODEC_TO_CONTAINER_MAP[codec] + if fmt in FORMAT_INFO: + return fmt - # Container-based fallback (lowest priority) - # Ensure the container is in FORMAT_INFO if container in FORMAT_INFO: return container return None -def validate_format_compatibility(format_name: str, stream_info: Dict, - drop_video: bool, drop_subs: bool, input_file: Optional[str] = None) -> None: - """ - Ensure the chosen container can accommodate the streams we intend to keep. - """ - info = FORMAT_INFO.get(format_name) - if not info: - print(f"Warning: Unknown format '{format_name}'. Proceeding, but may fail.") - return - - if info['audio_only']: - if stream_info.get('has_video') and not drop_video: - raise ValueError( - f"Format '{format_name}' does not support video streams. " - "Please use --drop-video or choose a container that supports video." - ) - if stream_info.get('has_subtitle') and not drop_subs: - raise ValueError( - f"Format '{format_name}' does not support subtitle streams. " - "Please use --drop-subs or choose a container that supports subtitles." - ) - else: - # If video is present and not dropped, check if the container supports the video codec - if stream_info.get('has_video') and not drop_video and input_file: - from .ffmpeg import get_video_codec - video_codec = get_video_codec(input_file) - if video_codec and not is_video_codec_supported(format_name, video_codec): - raise ValueError( - f"Container '{format_name}' does not support video codec '{video_codec}'. " - "Please use --drop-video or choose a container that supports this video codec (e.g., MKV)." - ) - -def is_video_codec_supported(container_name: str, video_codec: str) -> bool: - """ - Check if a given container supports a specific video codec. - - Args: - container_name: Name of the container (e.g., 'mkv', 'ogg') - video_codec: Video codec name (e.g., 'theora', 'h264', 'png') - - Returns: - True if supported, False otherwise. - """ - support = CONTAINER_VIDEO_CODEC_SUPPORT.get(container_name, []) - if not support: - return False - if '*' in support: - return True - return video_codec in support - - def determine_output_format(stream_info: Dict, user_format: Optional[str], transcode_audio: Optional[str], input_file: Optional[str] = None) -> str: """ @@ -93,42 +50,78 @@ def determine_output_format(stream_info: Dict, user_format: Optional[str], If user_format is provided, use it. Else, try to detect the input file's container and codec, and use the recommended format. If detection fails or format is not supported, fallback to: - - MKV if video/subtitles exist and the video codec is not supported by the recommended container + - MKV if video/subtitles exist - MP3 if the audio codec is MP3 - MP4 (M4A) otherwise """ if user_format: return user_format - # Try to detect container and codec from input file if input_file: try: - from .ffmpeg import get_container_format, get_audio_codec, get_video_codec + from .ffmpeg import get_container_format, get_audio_codec container = get_container_format(input_file) audio_codec = get_audio_codec(input_file) - video_codec = get_video_codec(input_file) # NEW: get video codec - - # Determine recommended format based on audio codec fmt = determine_default_format(container, audio_codec) - - # If video is present and not dropped, check if the recommended container supports the video codec - if stream_info.get('has_video') and not transcode_audio: # transcode_audio doesn't affect video - # If the recommended container doesn't support the video codec, fallback to MKV - if fmt and not is_video_codec_supported(fmt, video_codec): - fmt = 'matroska' # MKV supports virtually all video codecs - # Optionally log a warning - # print(f"Warning: Container '{fmt}' does not support video codec '{video_codec}'. Falling back to MKV.") - if fmt in FORMAT_INFO: return fmt except Exception: pass - # Fallback: legacy behavior + # Fallback if stream_info.get('has_video') or stream_info.get('has_subtitle'): return 'matroska' audio_codec = stream_info.get('audio_codec', '') if audio_codec == 'mp3': return 'mp3' else: - return 'mp4' # .m4a + return 'mp4' + + +def validate_format_compatibility( + container: str, + stream_info: Dict, + drop_video: bool, + drop_subs: bool, + input_file: Optional[str] = None, + audio_codec: Optional[str] = None, + video_codec: Optional[str] = None, +) -> None: + """ + Ensure the chosen container and codec combination is valid. + + Raises: + ValueError: If the combination is incompatible. + """ + info = FORMAT_INFO.get(container) + if not info: + print(f"Warning: Unknown container '{container}'. Proceeding, but may fail.") + return + + # Check if container is audio-only and video is present (unless dropped) + if info['audio_only'] and stream_info.get('has_video') and not drop_video: + raise ValueError( + f"Container '{container}' does not support video streams. " + "Please use --drop-video or choose a container that supports video (e.g., MKV, MP4)." + ) + + # Check if audio codec is supported + if audio_codec and audio_codec != 'copy': + if not is_audio_codec_supported(container, audio_codec): + raise ValueError( + f"Container '{container}' does not support audio codec '{audio_codec}'. " + f"Please choose a different container or audio codec." + ) + + # Check if video codec is supported (if video is present and not dropped) + if stream_info.get('has_video') and not drop_video: + # Determine the video codec from input file if not provided + if video_codec is None and input_file: + from .ffmpeg import get_video_codec + video_codec = get_video_codec(input_file) + if video_codec and video_codec != 'copy': + if not is_video_codec_supported(container, video_codec): + raise ValueError( + f"Container '{container}' does not support video codec '{video_codec}'. " + f"Please choose a different container, drop video, or transcode video to a supported codec." + ) diff --git a/audio_splitter/main.py b/audio_splitter/main.py index 4c83bc9..9d8cd39 100644 --- a/audio_splitter/main.py +++ b/audio_splitter/main.py @@ -1,11 +1,18 @@ -"""Command‑line interface and entry point.""" +#!/usr/bin/env python3 +""" +Audio Splitter – Command‑Line Interface + +Split an audio file into tracks using a tracklist file. + +Usage: + audio_splitter input.mp3 tracklist.txt [OPTIONS] +""" -import argparse import os import sys import subprocess +import argparse -from .constants import DEFAULT_BAD_CHARS from .defaults import ( DEFAULT_FORMAT, DEFAULT_OUTPUT_TEMPLATE, @@ -29,45 +36,72 @@ from .defaults import ( from .core import split_audio from .tracklist import read_tracklist, parse_format + def main(): - parser = argparse.ArgumentParser(description="Split audio file using a tracklist.") + parser = argparse.ArgumentParser( + description="Split an audio file into tracks using a tracklist.", + epilog="Tracklist format: mm:ss track_name - author_name (or custom with --tracklist-format)" + ) + + # Positional parser.add_argument('input_file', help='Input audio file') parser.add_argument('tracklist_file', help='Tracklist file') - # Output options - parser.add_argument('--format', default=DEFAULT_FORMAT, help=f"Output container format (default: {DEFAULT_FORMAT})") - parser.add_argument('--transcode-to', default=DEFAULT_TRANSCODE_TO, help="Audio codec to transcode to (default: copy)") - parser.add_argument('--drop-video', action='store_true', default=DEFAULT_DROP_VIDEO, help="Drop video streams") - parser.add_argument('--drop-subs', action='store_true', default=DEFAULT_DROP_SUBS, help="Drop subtitle streams") + # Container and codec options + parser.add_argument('--container', default=DEFAULT_FORMAT, + help="Output container format (default: %(default)s)") + parser.add_argument('--audio-codec', default='copy', + help="Audio codec (copy or encoder name, e.g., libopus)") + parser.add_argument('--video-codec', default='copy', + help="Video codec (copy or encoder name, e.g., libx264)") + parser.add_argument('--subtitle-codec', default='copy', + help="Subtitle codec (copy or encoder name, e.g., srt)") + parser.add_argument('--drop-video', action='store_true', default=DEFAULT_DROP_VIDEO, + help="Drop video streams") + parser.add_argument('--drop-subs', action='store_true', default=DEFAULT_DROP_SUBS, + help="Drop subtitle streams") # Filename options - parser.add_argument('--number-tracks', action='store_true', default=DEFAULT_NUMBER_TRACKS, help="Prepend track numbers") - parser.add_argument('--output-template', default=DEFAULT_OUTPUT_TEMPLATE, help=f"Output filename template (default: {DEFAULT_OUTPUT_TEMPLATE})") - parser.add_argument('--replace-bad-chars', action='store_true', default=DEFAULT_REPLACE_BAD_CHARS, help="Replace bad characters") - parser.add_argument('--replacement-char', default=DEFAULT_REPLACEMENT_CHAR, help=f"Replacement character (default: {DEFAULT_REPLACEMENT_CHAR})") - parser.add_argument('--bad-chars', default=DEFAULT_BAD_CHARS, help=f"Bad characters to replace (default: {DEFAULT_BAD_CHARS})") - parser.add_argument('--skip-existing', action='store_true', default=DEFAULT_SKIP_EXISTING, help="Skip existing output files") + parser.add_argument('--number-tracks', action='store_true', default=DEFAULT_NUMBER_TRACKS, + help="Prepend track numbers") + parser.add_argument('--output-template', default=DEFAULT_OUTPUT_TEMPLATE, + help="Output filename template (default: %(default)s)") + parser.add_argument('--replace-bad-chars', action='store_true', default=DEFAULT_REPLACE_BAD_CHARS, + help="Replace bad characters") + parser.add_argument('--replacement-char', default=DEFAULT_REPLACEMENT_CHAR, + help="Replacement character (default: %(default)s)") + parser.add_argument('--bad-chars', default=DEFAULT_BAD_CHARS, + help="Bad characters to replace (default: %(default)s)") + parser.add_argument('--skip-existing', action='store_true', default=DEFAULT_SKIP_EXISTING, + help="Skip existing output files") - # Metadata options + # Metadata parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name") parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment") - parser.add_argument('--no-comment', action='store_true', default=DEFAULT_NO_COMMENT, help="Ignore comment") - parser.add_argument('--comment-stream', type=int, default=DEFAULT_COMMENT_STREAM, help="Comment stream index") - parser.add_argument('--merge-comments', action='store_true', default=DEFAULT_MERGE_COMMENTS, help="Merge all comments") - parser.add_argument('--comment-separator', default=DEFAULT_COMMENT_SEPARATOR, help=f"Separator for merged comments (default: {DEFAULT_COMMENT_SEPARATOR})") + parser.add_argument('--no-comment', action='store_true', default=DEFAULT_NO_COMMENT, + help="Ignore comment") + parser.add_argument('--comment-stream', type=int, default=DEFAULT_COMMENT_STREAM, + help="Comment stream index") + parser.add_argument('--merge-comments', action='store_true', default=DEFAULT_MERGE_COMMENTS, + help="Merge all comments") + parser.add_argument('--comment-separator', default=DEFAULT_COMMENT_SEPARATOR, + help="Separator for merged comments (default: %(default)s)") # Tracklist format - parser.add_argument('--tracklist-format', default=DEFAULT_TRACKLIST_FORMAT, help=f"Tracklist format (default: {DEFAULT_TRACKLIST_FORMAT})") + parser.add_argument('--tracklist-format', default=DEFAULT_TRACKLIST_FORMAT, + help="Tracklist format (default: %(default)s)") # Other - parser.add_argument('--delete-original', action='store_true', default=DEFAULT_DELETE_ORIGINAL, help="Delete original file after split") - parser.add_argument('--dry-run', action='store_true', help="Parse and display tracklist without splitting") + parser.add_argument('--delete-original', action='store_true', default=DEFAULT_DELETE_ORIGINAL, + help="Delete original file after split") + parser.add_argument('--dry-run', action='store_true', + help="Parse and display tracklist without splitting") + parser.add_argument('--output-dir', '-o', default=None, + help="Output directory (default: _splits)") args = parser.parse_args() - # -------------------------------------------------------------------------- - # Input validation - # -------------------------------------------------------------------------- + # Validate input if not os.path.exists(args.input_file): print(f"Error: Input file not found: {args.input_file}") sys.exit(1) @@ -76,12 +110,7 @@ def main(): print(f"Error: Tracklist file not found: {args.tracklist_file}") sys.exit(1) - # Ensure the replacement character is a single character. - if len(args.replacement_char) != 1: - print("Error: --replacement-char must be a single character.") - sys.exit(1) - - # Check that FFmpeg is installed. + # Check FFmpeg try: subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True) except (subprocess.CalledProcessError, FileNotFoundError): @@ -89,14 +118,14 @@ def main(): print(" - https://ffmpeg.org/download.html") sys.exit(1) - # Parse the tracklist using the user‑provided format. + # Parse tracklist format try: tokens = parse_format(args.tracklist_format) - except ValueError as error: - print(f"Error in --tracklist-format: {error}") + except ValueError as e: + print(f"Error in --tracklist-format: {e}") sys.exit(1) - # Read and parse the tracklist file. + # Read tracklist tracks = read_tracklist(args.tracklist_file, tokens) if not tracks: print("Error: No valid tracks found in tracklist file.") @@ -104,7 +133,6 @@ def main(): print(f"Found {len(tracks)} tracks.") - # Dry‑run mode: display parsed data and exit. if args.dry_run: print("\nParsed tracklist:") print("-" * 60) @@ -118,34 +146,22 @@ def main(): print(f"{idx:3d} | " + " | ".join(values)) print("-" * 60) print("Dry‑run complete. No files were created.") - sys.exit(0) + return - # Determine the output directory. + # Determine output directory if args.output_dir: output_dir = args.output_dir - print(f"Using custom output directory: {output_dir}") else: base_name = os.path.splitext(os.path.basename(args.input_file))[0] output_dir = base_name + "_splits" - print(f"Using default output directory: {output_dir}") - # Run the splitter. + # Run split try: split_audio(args.input_file, output_dir, tracks, args) - except (RuntimeError, ValueError) as error: - print(f"Error: {error}") + except Exception as e: + print(f"Error during split: {e}") sys.exit(1) - # -------------------------------------------------------------------------- - # Delete original file if requested and successful. - # -------------------------------------------------------------------------- - if args.delete_original: - try: - os.remove(args.input_file) - print(f"Deleted original file: {args.input_file}") - except OSError as e: - print(f"Warning: Could not delete original file: {e}") - print("Done!") -- 2.52.0 From f3abbb8f0c40ad437620a4feca92571dafa382b8 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Tue, 25 Aug 2026 17:00:43 +0500 Subject: [PATCH 4/8] FEATURE: adds quality flag for video stream --- audio_splitter/core.py | 3 +++ audio_splitter/ffmpeg.py | 57 ++++++++++++++++++++++++++++++---------- audio_splitter/main.py | 7 +++++ 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/audio_splitter/core.py b/audio_splitter/core.py index 08a4a12..2f49e3f 100644 --- a/audio_splitter/core.py +++ b/audio_splitter/core.py @@ -194,6 +194,9 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A subtitle_codec=args.subtitle_codec, metadata=metadata, cover_image_path=cover_image_path, + video_quality=getattr(args, 'video_quality', None), + drop_video=args.drop_video, + drop_subs=args.drop_subs, ) print(cmd) diff --git a/audio_splitter/ffmpeg.py b/audio_splitter/ffmpeg.py index 76b6463..b5119b4 100644 --- a/audio_splitter/ffmpeg.py +++ b/audio_splitter/ffmpeg.py @@ -281,30 +281,49 @@ def build_ffmpeg_command( subtitle_codec: Optional[str] = 'copy', metadata: Optional[Dict] = None, cover_image_path: Optional[str] = None, + video_quality: Optional[int] = None, + drop_video: bool = False, + drop_subs: bool = False, ) -> List[str]: """ - Construct the FFmpeg command line. + Construct the FFmpeg command line as a list of arguments. Args: - audio_codec: 'copy' or encoder name (e.g., 'libopus') - video_codec: 'copy' or encoder name (e.g., 'libx264') - subtitle_codec: 'copy' or encoder name (e.g., 'srt') + input_file: Path to the input media file. + start_seconds: Start time for the segment (in seconds). + duration_seconds: Duration of the segment (in seconds). + output_path: Destination path for the output file. + stream_info: Dictionary from get_stream_info(). + format_opt: Output container format (e.g., 'mp3', 'mkv'). + audio_codec: Audio codec to use ('copy' or encoder name like 'libopus'). + video_codec: Video codec to use ('copy' or encoder name). + subtitle_codec: Subtitle codec to use ('copy' or encoder name). + metadata: Optional dict of metadata key/value pairs to write. + cover_image_path: Path to extracted cover image (if any). + video_quality: Quality value for video encoder (e.g., 1-31, lower=better). + drop_video: If True, remove video streams. + drop_subs: If True, remove subtitle streams. + + Returns: + A list of command‑line arguments suitable for subprocess.run(). """ cmd = ['ffmpeg'] - # Add cover image if provided + # Add cover image as first input if provided if cover_image_path: cmd.extend(['-i', cover_image_path]) + + # Add main input file cmd.extend(['-i', input_file]) # Time options cmd.extend(['-ss', format_time(start_seconds), '-t', format_time(duration_seconds)]) - # Clear metadata + # Clear all original metadata. cmd.append('-map_metadata') cmd.append('-1') - # Apply custom metadata + # Apply custom metadata. if metadata: for key, value in metadata.items(): if value is not None and value != '': @@ -328,15 +347,22 @@ def build_ffmpeg_command( else: cmd.extend(['-c:a', 'copy']) - # Subtitle: none (we don't copy from original when using cover image) + # Subtitle: we don't copy subtitles when using cover image (they would be from main input) cmd.append('-sn') + # Video quality if specified + if video_quality is not None: + cmd.extend(['-q:v', str(video_quality)]) + else: - # Standard mapping: copy all streams by default, then filter based on options - if not stream_info.get('has_video') or drop_video: + # Standard mapping (no cover image) + if drop_video and drop_subs: cmd.extend(['-map', '0:a:0']) + elif drop_video: + cmd.extend(['-map', '0:a:0', '-map', '0:s?']) + elif drop_subs: + cmd.extend(['-map', '0:a:0', '-map', '0:v:0']) else: - # Map all streams cmd.extend(['-map', '0']) # Audio codec @@ -345,17 +371,20 @@ def build_ffmpeg_command( else: cmd.extend(['-c:a', 'copy']) - # Video codec (if video present and not dropped) - if stream_info.get('has_video') and not drop_video: + # Video codec + if not drop_video and stream_info.get('has_video'): if video_codec and video_codec != 'copy': cmd.extend(['-c:v', video_codec]) + # Add video quality if specified (only when re-encoding) + if video_quality is not None: + cmd.extend(['-q:v', str(video_quality)]) else: cmd.extend(['-c:v', 'copy']) else: cmd.append('-vn') # Subtitle codec - if stream_info.get('has_subtitle') and not drop_subs: + if not drop_subs and stream_info.get('has_subtitle'): if subtitle_codec and subtitle_codec != 'copy': cmd.extend(['-c:s', subtitle_codec]) else: diff --git a/audio_splitter/main.py b/audio_splitter/main.py index 9d8cd39..070be8f 100644 --- a/audio_splitter/main.py +++ b/audio_splitter/main.py @@ -60,6 +60,13 @@ def main(): help="Drop video streams") parser.add_argument('--drop-subs', action='store_true', default=DEFAULT_DROP_SUBS, help="Drop subtitle streams") + parser.add_argument( + '--video-quality', '-vq', + type=int, + default=None, + help="Video quality (integer, encoder-specific; usually 1-31, lower=better). " + "If omitted, FFmpeg default is used." + ) # Filename options parser.add_argument('--number-tracks', action='store_true', default=DEFAULT_NUMBER_TRACKS, -- 2.52.0 From 748532be7c42f759ccb1439019a40b084c2022d9 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Fri, 28 Aug 2026 11:04:40 +0500 Subject: [PATCH 5/8] FIX: codec/container/file_extension autodetection when default values are used --- audio_splitter/core.py | 38 ++++++----- audio_splitter/main.py | 150 ++++++++++++++++++++++++++++++----------- 2 files changed, 132 insertions(+), 56 deletions(-) diff --git a/audio_splitter/core.py b/audio_splitter/core.py index 2f49e3f..bfb35ef 100644 --- a/audio_splitter/core.py +++ b/audio_splitter/core.py @@ -6,7 +6,7 @@ import subprocess import sys from typing import List, Dict, Any -from .constants import FORMAT_INFO +from .constants import FORMAT_INFO, CODEC_TO_EXTENSION_MAP from .ffmpeg import ( get_audio_duration, get_stream_info, @@ -34,10 +34,11 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A output_directory: Directory where output files will be saved. tracks: List of dicts, each containing parsed fields (ts, tn, an, ...). args: Parsed command‑line arguments (namespace) with attributes: - - container: output container name + - container: output container name (or None for auto-detect) - audio_codec: audio codec (copy or encoder) - video_codec: video codec (copy or encoder) - subtitle_codec: subtitle codec (copy or encoder) + - video_quality: integer or None - drop_video: bool - drop_subs: bool - number_tracks: bool @@ -77,7 +78,6 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A # -------------------------------------------------------------------------- # 2. Determine output container # -------------------------------------------------------------------------- - # Use args.container if provided, otherwise auto-detect user_container = getattr(args, 'container', None) output_container = determine_output_format( stream_info, @@ -104,13 +104,30 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A raise RuntimeError(f"Compatibility error: {e}") # -------------------------------------------------------------------------- - # 4. Parse timestamps + # 4. Determine output audio codec and extension + # -------------------------------------------------------------------------- + # Determine the audio codec that will be used in the output + if args.audio_codec != 'copy': + output_audio_codec = args.audio_codec + else: + output_audio_codec = stream_info.get('audio_codec', '') + + # Choose extension based on audio codec if possible, otherwise fallback to container default + if output_audio_codec in CODEC_TO_EXTENSION_MAP: + extension = CODEC_TO_EXTENSION_MAP[output_audio_codec] + else: + extension = FORMAT_INFO.get(output_container, {}).get('ext', '.mkv') + + print(f"Output extension: {extension}") + + # -------------------------------------------------------------------------- + # 5. Parse timestamps # -------------------------------------------------------------------------- track_times = parse_track_timestamps(tracks) resolved_times = resolve_end_times(track_times, total_duration) # -------------------------------------------------------------------------- - # 5. Fetch original metadata (for fallbacks) + # 6. Fetch original metadata (for fallbacks) # -------------------------------------------------------------------------- input_metadata = get_metadata(input_file) original_album = input_metadata.get('album') @@ -127,13 +144,12 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A print(f" Stream {idx}: '{comment}'") # -------------------------------------------------------------------------- - # 6. Handle attached picture (cover art) + # 7. Handle attached picture (cover art) # -------------------------------------------------------------------------- cover_image_path = getattr(args, 'cover_image', None) if cover_image_path and not os.path.exists(cover_image_path): cover_image_path = None - # If not provided via args (CLI case), try to detect and extract if not cover_image_path and not args.drop_video and stream_info.get('has_video'): if is_attached_picture(input_file): cover_image_path = os.path.join(output_directory, 'cover.png') @@ -142,12 +158,6 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A else: cover_image_path = None - # -------------------------------------------------------------------------- - # 7. Determine file extension - # -------------------------------------------------------------------------- - extension_info = FORMAT_INFO.get(output_container, {}) - extension = extension_info.get('ext', '.mkv') - # -------------------------------------------------------------------------- # 8. Process each track # -------------------------------------------------------------------------- @@ -199,7 +209,6 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A drop_subs=args.drop_subs, ) - print(cmd) print(f"Extracting track {idx}: {track_name} " f"({format_time(start_seconds)} - {format_time(end_seconds)})") @@ -208,7 +217,6 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A if result.returncode != 0: print(f"ERROR extracting track {idx}:") print(result.stderr) - # Optionally stop on first error? We'll continue. else: print(f" -> Saved to: {output_path}") diff --git a/audio_splitter/main.py b/audio_splitter/main.py index 070be8f..fce53e3 100644 --- a/audio_splitter/main.py +++ b/audio_splitter/main.py @@ -31,7 +31,6 @@ from .defaults import ( DEFAULT_REPLACE_BAD_CHARS, DEFAULT_SKIP_EXISTING, DEFAULT_DELETE_ORIGINAL, - DEFAULT_TRANSCODE_TO, ) from .core import split_audio from .tracklist import read_tracklist, parse_format @@ -48,18 +47,26 @@ def main(): parser.add_argument('tracklist_file', help='Tracklist file') # Container and codec options - parser.add_argument('--container', default=DEFAULT_FORMAT, - help="Output container format (default: %(default)s)") - parser.add_argument('--audio-codec', default='copy', - help="Audio codec (copy or encoder name, e.g., libopus)") - parser.add_argument('--video-codec', default='copy', - help="Video codec (copy or encoder name, e.g., libx264)") - parser.add_argument('--subtitle-codec', default='copy', - help="Subtitle codec (copy or encoder name, e.g., srt)") - parser.add_argument('--drop-video', action='store_true', default=DEFAULT_DROP_VIDEO, - help="Drop video streams") - parser.add_argument('--drop-subs', action='store_true', default=DEFAULT_DROP_SUBS, - help="Drop subtitle streams") + parser.add_argument( + '--container', + default=None, + help="Output container format (auto-detect if not specified)" + ) + parser.add_argument( + '--audio-codec', + default='copy', + help="Audio codec (copy or encoder name, e.g., libopus)" + ) + parser.add_argument( + '--video-codec', + default='copy', + help="Video codec (copy or encoder name, e.g., libx264)" + ) + parser.add_argument( + '--subtitle-codec', + default='copy', + help="Subtitle codec (copy or encoder name, e.g., srt)" + ) parser.add_argument( '--video-quality', '-vq', type=int, @@ -67,44 +74,105 @@ def main(): help="Video quality (integer, encoder-specific; usually 1-31, lower=better). " "If omitted, FFmpeg default is used." ) + parser.add_argument( + '--drop-video', + action='store_true', + default=DEFAULT_DROP_VIDEO, + help="Drop video streams" + ) + parser.add_argument( + '--drop-subs', + action='store_true', + default=DEFAULT_DROP_SUBS, + help="Drop subtitle streams" + ) # Filename options - parser.add_argument('--number-tracks', action='store_true', default=DEFAULT_NUMBER_TRACKS, - help="Prepend track numbers") - parser.add_argument('--output-template', default=DEFAULT_OUTPUT_TEMPLATE, - help="Output filename template (default: %(default)s)") - parser.add_argument('--replace-bad-chars', action='store_true', default=DEFAULT_REPLACE_BAD_CHARS, - help="Replace bad characters") - parser.add_argument('--replacement-char', default=DEFAULT_REPLACEMENT_CHAR, - help="Replacement character (default: %(default)s)") - parser.add_argument('--bad-chars', default=DEFAULT_BAD_CHARS, - help="Bad characters to replace (default: %(default)s)") - parser.add_argument('--skip-existing', action='store_true', default=DEFAULT_SKIP_EXISTING, - help="Skip existing output files") + parser.add_argument( + '--number-tracks', + action='store_true', + default=DEFAULT_NUMBER_TRACKS, + help="Prepend track numbers" + ) + parser.add_argument( + '--output-template', + default=DEFAULT_OUTPUT_TEMPLATE, + help="Output filename template (default: %(default)s)" + ) + parser.add_argument( + '--replace-bad-chars', + action='store_true', + default=DEFAULT_REPLACE_BAD_CHARS, + help="Replace bad characters" + ) + parser.add_argument( + '--replacement-char', + default=DEFAULT_REPLACEMENT_CHAR, + help="Replacement character (default: %(default)s)" + ) + parser.add_argument( + '--bad-chars', + default=DEFAULT_BAD_CHARS, + help="Bad characters to replace (default: %(default)s)" + ) + parser.add_argument( + '--skip-existing', + action='store_true', + default=DEFAULT_SKIP_EXISTING, + help="Skip existing output files" + ) # Metadata parser.add_argument('--album', default=DEFAULT_ALBUM, help="Album name") parser.add_argument('--comment', default=DEFAULT_COMMENT, help="Comment") - parser.add_argument('--no-comment', action='store_true', default=DEFAULT_NO_COMMENT, - help="Ignore comment") - parser.add_argument('--comment-stream', type=int, default=DEFAULT_COMMENT_STREAM, - help="Comment stream index") - parser.add_argument('--merge-comments', action='store_true', default=DEFAULT_MERGE_COMMENTS, - help="Merge all comments") - parser.add_argument('--comment-separator', default=DEFAULT_COMMENT_SEPARATOR, - help="Separator for merged comments (default: %(default)s)") + parser.add_argument( + '--no-comment', + action='store_true', + default=DEFAULT_NO_COMMENT, + help="Ignore comment" + ) + parser.add_argument( + '--comment-stream', + type=int, + default=DEFAULT_COMMENT_STREAM, + help="Comment stream index" + ) + parser.add_argument( + '--merge-comments', + action='store_true', + default=DEFAULT_MERGE_COMMENTS, + help="Merge all comments" + ) + parser.add_argument( + '--comment-separator', + default=DEFAULT_COMMENT_SEPARATOR, + help="Separator for merged comments (default: %(default)s)" + ) # Tracklist format - parser.add_argument('--tracklist-format', default=DEFAULT_TRACKLIST_FORMAT, - help="Tracklist format (default: %(default)s)") + parser.add_argument( + '--tracklist-format', + default=DEFAULT_TRACKLIST_FORMAT, + help="Tracklist format (default: %(default)s)" + ) # Other - parser.add_argument('--delete-original', action='store_true', default=DEFAULT_DELETE_ORIGINAL, - help="Delete original file after split") - parser.add_argument('--dry-run', action='store_true', - help="Parse and display tracklist without splitting") - parser.add_argument('--output-dir', '-o', default=None, - help="Output directory (default: _splits)") + parser.add_argument( + '--delete-original', + action='store_true', + default=DEFAULT_DELETE_ORIGINAL, + help="Delete original file after split" + ) + parser.add_argument( + '--dry-run', + action='store_true', + help="Parse and display tracklist without splitting" + ) + parser.add_argument( + '--output-dir', '-o', + default=None, + help="Output directory (default: _splits)" + ) args = parser.parse_args() -- 2.52.0 From 834239674aee8b8eb19a92c263ea75987a6d0479 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Fri, 28 Aug 2026 13:24:46 +0500 Subject: [PATCH 6/8] FEATURE: propogate improvements from CLI version to web-backend --- web/backend/api/__init__.py | 2 +- web/backend/api/split.py | 34 ++++++++++-- web/backend/api/split_file.py | 85 ++++++++++++++++------------- web/backend/models/request.py | 32 +++++++++-- web/backend/services/__init__.py | 2 +- web/backend/services/splitter.py | 93 ++++++++++++++++---------------- 6 files changed, 156 insertions(+), 92 deletions(-) diff --git a/web/backend/api/__init__.py b/web/backend/api/__init__.py index ae73d86..e93b233 100644 --- a/web/backend/api/__init__.py +++ b/web/backend/api/__init__.py @@ -1,3 +1,3 @@ """API route handlers.""" -from . import upload, split, split_file, status, download, formats, info +from . import upload, split, split_file, status, download, websocket, formats, info diff --git a/web/backend/api/split.py b/web/backend/api/split.py index 9967939..a3b2e64 100644 --- a/web/backend/api/split.py +++ b/web/backend/api/split.py @@ -1,4 +1,4 @@ -"""Split task endpoint.""" +"""Split task endpoint (JSON tracklist).""" from fastapi import APIRouter, HTTPException, BackgroundTasks @@ -24,6 +24,29 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks): detail=f"Task {task_id} is already {status['status']}" ) + # Build options dict from request + options = { + "container": request.container, + "audio_codec": request.audio_codec, + "video_codec": request.video_codec, + "subtitle_codec": request.subtitle_codec, + "video_quality": request.video_quality, + "drop_video": request.drop_video, + "drop_subs": request.drop_subs, + "number_tracks": request.number_tracks, + "replace_bad_chars": request.replace_bad_chars, + "replacement_char": request.replacement_char, + "bad_chars": request.bad_chars, + "skip_existing": request.skip_existing, + "output_template": request.output_template, + "album": request.album, + "comment": request.comment, + "no_comment": request.no_comment, + "comment_stream": request.comment_stream, + "merge_comments": request.merge_comments, + "comment_separator": request.comment_separator, + } + task_manager.update_task( task_id, status=TaskStatus.PROCESSING, @@ -31,9 +54,14 @@ async def start_split(request: SplitRequest, background_tasks: BackgroundTasks): message="Preparing to split..." ) - background_tasks.add_task(run_split_task, task_id, request.tracklist, request.options) + background_tasks.add_task( + run_split_task, + task_id, + request.tracklist, + options + ) return SplitResponse( task_id=task_id, status=TaskStatus.PROCESSING - ) \ No newline at end of file + ) diff --git a/web/backend/api/split_file.py b/web/backend/api/split_file.py index 1c0a354..70e8627 100644 --- a/web/backend/api/split_file.py +++ b/web/backend/api/split_file.py @@ -2,6 +2,7 @@ import json from pathlib import Path +from typing import Optional from fastapi import APIRouter, UploadFile, File, Form, HTTPException, BackgroundTasks @@ -13,7 +14,6 @@ from backend.models.response import SplitResponse, TaskStatus router = APIRouter(prefix="/api", tags=["split"]) -# Import CLI tracklist parsing functions from audio_splitter.tracklist import parse_format, read_tracklist @@ -22,29 +22,33 @@ async def split_from_file( background_tasks: BackgroundTasks, task_id: str = Form(...), tracklist_file: UploadFile = File(...), - options: str = Form("{}"), + # New fields + container: Optional[str] = Form(None), + audio_codec: str = Form("copy"), + video_codec: str = Form("copy"), + subtitle_codec: str = Form("copy"), + video_quality: Optional[int] = Form(None), + drop_video: bool = Form(False), + drop_subs: bool = Form(False), + number_tracks: bool = Form(False), + replace_bad_chars: bool = Form(False), + replacement_char: str = Form("_"), + bad_chars: str = Form(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '), + skip_existing: bool = Form(False), + output_template: str = Form("%an-%tn.%ext"), + album: Optional[str] = Form(None), + comment: Optional[str] = Form(None), + no_comment: bool = Form(False), + comment_stream: Optional[int] = Form(None), + merge_comments: bool = Form(False), + comment_separator: str = Form("; "), + options: str = Form("{}"), # backward-compatible, but we now use explicit fields tracklist_format: str = Form("%ts %tn - %an"), ): - """ - Start a splitting task using a tracklist file. - - This endpoint accepts a plain text tracklist file (like the CLI does) - and parses it using the same logic. - - Args: - task_id: Task ID from upload. - tracklist_file: Tracklist file (text/plain). - options: JSON string of all CLI options. - tracklist_format: Format string for parsing (default: "%ts %tn - %an"). - - Returns: - SplitResponse with task_id and status. - """ # Validate task exists if not task_manager.has_task(task_id): raise HTTPException(status_code=404, detail=f"Task {task_id} not found") - # Check if task is already processing status = task_manager.get_status(task_id) if status and status["status"] in (TaskStatus.PROCESSING, TaskStatus.DONE): raise HTTPException( @@ -52,14 +56,12 @@ async def split_from_file( detail=f"Task {task_id} is already {status['status']}" ) - # Validate file type if not tracklist_file.filename.endswith(('.txt', '.text')): raise HTTPException( status_code=400, detail="Tracklist file must be a text file (.txt or .text)" ) - # Read and parse the tracklist file try: content = await tracklist_file.read() text = content.decode('utf-8') @@ -75,17 +77,13 @@ async def split_from_file( detail="Tracklist file is empty" ) - # Parse the tracklist using CLI logic + # Parse tracklist using CLI logic try: - # Parse the format string into tokens tokens = parse_format(tracklist_format) - - # Write the content to a temporary file (read_tracklist expects a file path) temp_tracklist_path = settings.temp_dir / task_id / "tracklist.txt" temp_tracklist_path.parent.mkdir(parents=True, exist_ok=True) temp_tracklist_path.write_text(text, encoding='utf-8') - # Parse the tracklist file using CLI logic tracklist_dicts = read_tracklist(str(temp_tracklist_path), tokens) except Exception as e: @@ -100,7 +98,7 @@ async def split_from_file( detail="No valid tracks found in tracklist file" ) - # Convert dicts to TracklistEntry objects (same as JSON endpoint) + # Convert dicts to TracklistEntry objects try: tracklist_entries = [TracklistEntry(**entry) for entry in tracklist_dicts] except Exception as e: @@ -109,14 +107,28 @@ async def split_from_file( detail=f"Invalid tracklist data: {str(e)}" ) - # Parse options JSON - try: - options_dict = json.loads(options) - except json.JSONDecodeError: - raise HTTPException( - status_code=400, - detail="Invalid JSON in options field" - ) + # Build options dict from explicit fields (ignore `options` parameter) + options = { + "container": container, + "audio_codec": audio_codec, + "video_codec": video_codec, + "subtitle_codec": subtitle_codec, + "video_quality": video_quality, + "drop_video": drop_video, + "drop_subs": drop_subs, + "number_tracks": number_tracks, + "replace_bad_chars": replace_bad_chars, + "replacement_char": replacement_char, + "bad_chars": bad_chars, + "skip_existing": skip_existing, + "output_template": output_template, + "album": album, + "comment": comment, + "no_comment": no_comment, + "comment_stream": comment_stream, + "merge_comments": merge_comments, + "comment_separator": comment_separator, + } # Update task status task_manager.update_task( @@ -126,12 +138,11 @@ async def split_from_file( message="Preparing to split..." ) - # Start background task background_tasks.add_task( run_split_task, task_id, - tracklist_entries, # now TracklistEntry objects - options_dict + tracklist_entries, + options ) return SplitResponse( diff --git a/web/backend/models/request.py b/web/backend/models/request.py index 73ec21b..bbc8e83 100644 --- a/web/backend/models/request.py +++ b/web/backend/models/request.py @@ -17,19 +17,16 @@ class TracklistEntry(BaseModel): @validator("ts") def validate_timestamp(cls, v: str) -> str: - """Basic timestamp validation (format and range).""" v = v.strip() if not v: raise ValueError("Timestamp cannot be empty") - # Check for range format (start-end) if "-" in v: parts = v.split("-", 1) start = parts[0].strip() end = parts[1].strip() if not start or not end: raise ValueError("Invalid range format. Expected 'start-end'") - # Validate each part with the same logic for ts in [start, end]: cls._validate_single_timestamp(ts) else: @@ -39,7 +36,6 @@ class TracklistEntry(BaseModel): @staticmethod def _validate_single_timestamp(ts: str) -> None: - """Validate a single timestamp (mm:ss or HH:MM:SS).""" parts = ts.split(":") if len(parts) not in (2, 3): raise ValueError(f"Invalid timestamp format: {ts}. Expected mm:ss or HH:MM:SS") @@ -55,4 +51,30 @@ class SplitRequest(BaseModel): task_id: str = Field(..., description="Task ID from upload") tracklist: List[TracklistEntry] = Field(..., description="List of tracks") - options: dict = Field(default_factory=dict, description="All CLI options") \ No newline at end of file + + # Container and codec options + container: Optional[str] = Field(None, description="Output container (auto-detect if None)") + audio_codec: Optional[str] = Field("copy", description="Audio codec (copy or encoder name)") + video_codec: Optional[str] = Field("copy", description="Video codec (copy or encoder name)") + subtitle_codec: Optional[str] = Field("copy", description="Subtitle codec (copy or encoder name)") + video_quality: Optional[int] = Field(None, description="Video quality (encoder-specific integer)") + + # Stream handling + drop_video: bool = Field(False, description="Remove video streams") + drop_subs: bool = Field(False, description="Remove subtitle streams") + + # Filename options + number_tracks: bool = Field(False, description="Prepend track numbers") + replace_bad_chars: bool = Field(False, description="Replace bad characters") + replacement_char: str = Field("_", description="Replacement character") + bad_chars: str = Field(r'!@#№$;:%^&?*(){}[]\/<>+=~`\' ', description="Bad characters to replace") + skip_existing: bool = Field(False, description="Skip existing files") + output_template: str = Field("%an-%tn.%ext", description="Output filename template") + + # Metadata options + album: Optional[str] = Field(None, description="Album name") + comment: Optional[str] = Field(None, description="Comment") + no_comment: bool = Field(False, description="Ignore comment") + comment_stream: Optional[int] = Field(None, description="Comment stream index") + merge_comments: bool = Field(False, description="Merge all comments") + comment_separator: str = Field("; ", description="Separator for merged comments") diff --git a/web/backend/services/__init__.py b/web/backend/services/__init__.py index ee78a45..de2060f 100644 --- a/web/backend/services/__init__.py +++ b/web/backend/services/__init__.py @@ -1 +1 @@ -"""Business logic services.""" \ No newline at end of file +"""Business logic services.""" diff --git a/web/backend/services/splitter.py b/web/backend/services/splitter.py index 6c9b501..fa82df8 100644 --- a/web/backend/services/splitter.py +++ b/web/backend/services/splitter.py @@ -2,10 +2,10 @@ import os import sys -import time +import shutil from pathlib import Path -from types import SimpleNamespace -from typing import Dict, List, Any +from typing import List, Dict, Any +import time from backend.config import settings from backend.services.task_manager import task_manager @@ -13,31 +13,12 @@ from backend.services.file_manager import FileManager from backend.models.request import TracklistEntry from backend.models.response import TaskStatus -# Import central defaults -from audio_splitter.defaults import ( - DEFAULT_FORMAT, - DEFAULT_OUTPUT_TEMPLATE, - DEFAULT_REPLACEMENT_CHAR, - DEFAULT_BAD_CHARS, - DEFAULT_ALBUM, - DEFAULT_COMMENT, - DEFAULT_NO_COMMENT, - DEFAULT_COMMENT_STREAM, - DEFAULT_MERGE_COMMENTS, - DEFAULT_COMMENT_SEPARATOR, - DEFAULT_DROP_VIDEO, - DEFAULT_DROP_SUBS, - DEFAULT_NUMBER_TRACKS, - DEFAULT_REPLACE_BAD_CHARS, - DEFAULT_SKIP_EXISTING, - DEFAULT_DELETE_ORIGINAL, - DEFAULT_TRANSCODE_TO, -) - def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[str, Any]) -> None: try: - task_manager.update_task_with_progress(task_id, progress=5, message="Initializing...") + task_manager.update_task_with_progress( + task_id, progress=5, message="Initializing..." + ) input_path = FileManager.get_input_path(task_id) if not input_path or not input_path.exists(): @@ -45,7 +26,6 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[ output_dir = FileManager.ensure_output_dir(task_id) - # Convert tracklist to dicts (CLI format) tracks = [] for entry in tracklist: track_dict = { @@ -58,25 +38,30 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[ } tracks.append(track_dict) - # Build args namespace using defaults where options not provided + from types import SimpleNamespace + + # Build args namespace using the new fields args = SimpleNamespace( - format=options.get("format", DEFAULT_FORMAT), - transcode_to=options.get("transcode_to", DEFAULT_TRANSCODE_TO), - drop_video=options.get("drop_video", DEFAULT_DROP_VIDEO), - drop_subs=options.get("drop_subs", DEFAULT_DROP_SUBS), - number_tracks=options.get("number_tracks", DEFAULT_NUMBER_TRACKS), - replace_bad_chars=options.get("replace_bad_chars", DEFAULT_REPLACE_BAD_CHARS), - replacement_char=options.get("replacement_char", DEFAULT_REPLACEMENT_CHAR), - bad_chars=options.get("bad_chars", DEFAULT_BAD_CHARS), - skip_existing=options.get("skip_existing", DEFAULT_SKIP_EXISTING), - output_template=options.get("output_template", DEFAULT_OUTPUT_TEMPLATE), - album=options.get("album", DEFAULT_ALBUM), - comment=options.get("comment", DEFAULT_COMMENT), - no_comment=options.get("no_comment", DEFAULT_NO_COMMENT), - comment_stream=options.get("comment_stream", DEFAULT_COMMENT_STREAM), - merge_comments=options.get("merge_comments", DEFAULT_MERGE_COMMENTS), - comment_separator=options.get("comment_separator", DEFAULT_COMMENT_SEPARATOR), - delete_original=DEFAULT_DELETE_ORIGINAL, # never delete in web + container=options.get("container"), # None -> auto-detect + audio_codec=options.get("audio_codec", "copy"), + video_codec=options.get("video_codec", "copy"), + subtitle_codec=options.get("subtitle_codec", "copy"), + video_quality=options.get("video_quality"), + drop_video=options.get("drop_video", False), + drop_subs=options.get("drop_subs", False), + number_tracks=options.get("number_tracks", False), + replace_bad_chars=options.get("replace_bad_chars", False), + replacement_char=options.get("replacement_char", "_"), + bad_chars=options.get("bad_chars", r'!@#№$;:%^&?*(){}[]\/<>+=~`\' '), + skip_existing=options.get("skip_existing", False), + output_template=options.get("output_template", "%an-%tn.%ext"), + album=options.get("album", None), + comment=options.get("comment", None), + no_comment=options.get("no_comment", False), + comment_stream=options.get("comment_stream", None), + merge_comments=options.get("merge_comments", False), + comment_separator=options.get("comment_separator", "; "), + delete_original=False, ) project_root = Path(__file__).parent.parent.parent.parent @@ -85,7 +70,25 @@ def run_split_task(task_id: str, tracklist: List[TracklistEntry], options: Dict[ from audio_splitter.core import split_audio - task_manager.update_task_with_progress(task_id, progress=10, message="Starting split...") + # Detect attached picture and extract if applicable + cover_image_path = None + if not args.drop_video: + from audio_splitter.ffmpeg import is_attached_picture, extract_cover_image, get_stream_info + input_path_str = str(input_path) + stream_info = get_stream_info(input_path_str) + if stream_info.get('has_video') and is_attached_picture(input_path_str): + cover_image_path = output_dir / 'cover.png' + if extract_cover_image(input_path_str, str(cover_image_path)): + print(f"Extracted cover image for task {task_id}") + else: + cover_image_path = None + + # Attach cover image to args + args.cover_image = str(cover_image_path) if cover_image_path else None + + task_manager.update_task_with_progress( + task_id, progress=10, message="Starting split..." + ) split_audio(str(input_path), str(output_dir), tracks, args) -- 2.52.0 From 3533525df09ad76eea550450668f8832f3c8eb4b Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Fri, 28 Aug 2026 15:40:38 +0500 Subject: [PATCH 7/8] FEATURE: propagate improvements from CLI and web-API versions to web-frontend --- web/frontend/src/App.tsx | 15 +- web/frontend/src/api/client.ts | 52 +++-- web/frontend/src/components/OptionsPanel.tsx | 190 +++++++++++++------ web/frontend/src/components/UploadZone.tsx | 5 +- web/frontend/src/stores/optionsStore.ts | 37 ++-- web/frontend/src/types/index.ts | 57 +++--- 6 files changed, 236 insertions(+), 120 deletions(-) diff --git a/web/frontend/src/App.tsx b/web/frontend/src/App.tsx index 7c48eac..52a73b9 100644 --- a/web/frontend/src/App.tsx +++ b/web/frontend/src/App.tsx @@ -68,16 +68,15 @@ const App: React.FC = () => { reset() } - const isSplitDisabled = !taskId || !isValid || entries.length === 0 || isProcessing || !!formatError + const isSplitDisabled = + !taskId || + !isValid || + entries.length === 0 || + isProcessing || + !!formatError return ( - + diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts index 546044f..aa6b0e6 100644 --- a/web/frontend/src/api/client.ts +++ b/web/frontend/src/api/client.ts @@ -1,7 +1,12 @@ -// web/frontend/src/api/client.ts - import axios from 'axios' -import { TracklistEntry, SplitOptions, TaskStatus, FormatsResponse } from '../types' +import { + TracklistEntry, + SplitOptions, + TaskStatus, + FormatsResponse, + UploadResponse, + SplitResponse, +} from '../types' export const api = axios.create({ baseURL: '/api', @@ -10,16 +15,14 @@ export const api = axios.create({ }, }) -export const uploadFile = async (file: File): Promise<{ task_id: string; filename: string; size: number }> => { +export const uploadFile = async (file: File): Promise => { const formData = new FormData() formData.append('file', file) - const response = await api.post('/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', }, }) - return response.data } @@ -27,12 +30,32 @@ export const startSplit = async ( task_id: string, tracklist: TracklistEntry[], options: SplitOptions -): Promise<{ task_id: string; status: string }> => { - const response = await api.post('/split', { +): Promise => { + // Build request payload (only send fields that are not undefined) + const payload: any = { task_id, tracklist, - options, - }) + container: options.container, // null means auto-detect + audio_codec: options.audio_codec, + video_codec: options.video_codec, + subtitle_codec: options.subtitle_codec, + video_quality: options.video_quality, + drop_video: options.drop_video, + drop_subs: options.drop_subs, + number_tracks: options.number_tracks, + replace_bad_chars: options.replace_bad_chars, + replacement_char: options.replacement_char, + bad_chars: options.bad_chars, + skip_existing: options.skip_existing, + output_template: options.output_template, + album: options.album || null, + comment: options.comment || null, + no_comment: options.no_comment, + comment_stream: options.comment_stream, + merge_comments: options.merge_comments, + comment_separator: options.comment_separator, + } + const response = await api.post('/split', payload) return response.data } @@ -49,8 +72,7 @@ export const getDownloadZipUrl = (task_id: string): string => { return `/api/download/${task_id}/splits.zip` } -// New functions for format validation feature -export const getFormatInfo = async (): Promise<{ formats: Array<{ name: string; audio_only: boolean }> }> => { +export const getFormats = async (): Promise => { const response = await api.get('/formats') return response.data } @@ -70,9 +92,3 @@ 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 dbd5aaf..eaec8a2 100644 --- a/web/frontend/src/components/OptionsPanel.tsx +++ b/web/frontend/src/components/OptionsPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useMemo } from 'react' import { Box, Paper, @@ -17,6 +17,7 @@ import { useOptionsStore } from '../stores/optionsStore' import { useUploadStore } from '../stores/uploadStore' import { useValidationStore } from '../stores/validationStore' import { getFormats } from '../api/client' +import { SplitOptions } from '../types' // ------------------------------------------------------------------------------ // Section component (collapsible) @@ -29,7 +30,6 @@ interface SectionProps { const Section: React.FC = ({ title, children, defaultExpanded = false }) => { const [expanded, setExpanded] = React.useState(defaultExpanded) - return ( = ({ title, children, defaultExpanded = fa } // ------------------------------------------------------------------------------ -// Main OptionsPanel component +// Main OptionsPanel // ------------------------------------------------------------------------------ export const OptionsPanel: React.FC = () => { const { @@ -64,57 +64,103 @@ export const OptionsPanel: React.FC = () => { setOptions, containers, codecs, + compatibility, setContainers, setCodecs, + setCompatibility, } = useOptionsStore() const { hasVideo } = useUploadStore() const { formatError, setFormatError } = useValidationStore() - // Fetch containers and codecs from backend on mount + // Fetch formats on mount useEffect(() => { const fetchFormats = async () => { try { const data = await getFormats() - setContainers(data.containers) - setCodecs(data.codecs) + setContainers(data.containers || []) + setCodecs(data.codecs || []) + setCompatibility(data.compatibility || {}) } catch (err) { console.error('Failed to fetch formats:', err) } } fetchFormats() - }, [setContainers, setCodecs]) + }, [setContainers, setCodecs, setCompatibility]) + + // -------------------------------------------------------------- + // Filter codec options based on selected container + // -------------------------------------------------------------- + const filteredAudioCodecs = useMemo(() => { + const entry = compatibility?.[options.container || ''] + if (!entry) return codecs.filter(c => c.supports_transcoding) + const audioList = entry.audio + if (audioList === null) return codecs.filter(c => c.supports_transcoding) + return codecs.filter(c => c.supports_transcoding && audioList.includes(c.name)) + }, [compatibility, options.container, codecs]) + + const filteredVideoCodecs = useMemo(() => { + const entry = compatibility?.[options.container || ''] + if (!entry) return codecs.filter(c => c.supports_video) + const videoList = entry.video + if (videoList === null) return codecs.filter(c => c.supports_video) + return codecs.filter(c => c.supports_video && videoList.includes(c.name)) + }, [compatibility, options.container, codecs]) + + // -------------------------------------------------------------- + // Validate compatibility + // -------------------------------------------------------------- + useEffect(() => { + const selectedContainer = containers.find(c => c.name === options.container) + if (selectedContainer?.audio_only && hasVideo && !options.drop_video) { + setFormatError( + `Container '${options.container}' does not support video streams. Please enable "Drop video" or choose a container that supports video.` + ) + return + } + + // Check audio codec compatibility + if (options.audio_codec && options.audio_codec !== 'copy' && options.container) { + const entry = compatibility?.[options.container] + if (entry) { + const audioList = entry.audio + if (audioList !== null && !audioList.includes(options.audio_codec)) { + setFormatError( + `Audio codec '${options.audio_codec}' is not supported by container '${options.container}'.` + ) + return + } + } + } + + // Check video codec compatibility + if (options.video_codec && options.video_codec !== 'copy' && options.container && hasVideo && !options.drop_video) { + const entry = compatibility?.[options.container] + if (entry) { + const videoList = entry.video + if (videoList !== null && !videoList.includes(options.video_codec)) { + setFormatError( + `Video codec '${options.video_codec}' is not supported by container '${options.container}'.` + ) + return + } + } + } + + setFormatError(null) + }, [options.container, options.audio_codec, options.video_codec, options.drop_video, hasVideo, compatibility, containers, setFormatError]) // -------------------------------------------------------------- // Handlers // -------------------------------------------------------------- - const handleFormatChange = (e: React.ChangeEvent) => { - const newFormat = e.target.value - setOptions({ format: newFormat }) - } - - const handleDropVideoChange = (e: React.ChangeEvent) => { - const checked = e.target.checked - setOptions({ drop_video: checked }) - } - - const handleChange = (field: string, value: any) => { + const handleChange = (field: keyof SplitOptions, 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( - `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) - } - }, [containers, options.format, options.drop_video, hasVideo, setFormatError]) + const handleContainerChange = (e: React.ChangeEvent) => { + const value = e.target.value === '' ? null : e.target.value + handleChange('container', value) + } // -------------------------------------------------------------- // Render @@ -140,56 +186,92 @@ export const OptionsPanel: React.FC = () => { - {containers.map((container) => ( - - {container.name.toUpperCase()} ({container.extension}) + Auto-detect + {containers.map((c) => ( + + {c.name.toUpperCase()} ({c.extension}) ))} - {/* Audio Codec (Transcode) dropdown */} + {/* Audio Codec dropdown */} handleChange('transcode_to', e.target.value || undefined)} + value={options.audio_codec} + onChange={(e) => handleChange('audio_codec', e.target.value)} fullWidth size="small" - helperText="Select an audio codec to re-encode, or keep 'Copy' to preserve the original." + helperText="Audio codec (copy = keep original)" > - Copy (no transcoding) - {codecs - .filter(c => c.supports_transcoding) - .map((codec) => ( - - {codec.name.toUpperCase()} (→ {codec.recommended_container}) - - ))} + Copy (original) + {filteredAudioCodecs.map((c) => ( + + {c.name.toUpperCase()} + + ))} - {/* Drop video stream (only shown if video present) */} + {/* Video Codec dropdown */} + handleChange('video_codec', e.target.value)} + fullWidth + size="small" + helperText="Video codec (copy = keep original)" + disabled={!hasVideo || options.drop_video} + > + Copy (original) + {filteredVideoCodecs.map((c) => ( + + {c.name.toUpperCase()} + + ))} + + + {/* Video Quality */} + { + const val = e.target.value === '' ? null : parseInt(e.target.value, 10) + handleChange('video_quality', val) + }} + fullWidth + size="small" + disabled={!hasVideo || options.drop_video} + helperText="Optional quality value (encoder-specific; e.g., 1-31 for libx264, 0-10 for Theora)" + InputProps={{ + inputProps: { min: 0, max: 51, step: 1 }, + }} + /> + + {/* Drop video (if video present) */} {hasVideo && ( handleChange('drop_video', e.target.checked)} /> } label="Drop video streams" /> )} - {/* Drop subtitle streams */} + {/* Drop subtitles */} { type="number" value={options.comment_stream ?? ''} onChange={(e) => - handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value)) + handleChange('comment_stream', e.target.value === '' ? null : parseInt(e.target.value, 10)) } size="small" disabled={options.no_comment} diff --git a/web/frontend/src/components/UploadZone.tsx b/web/frontend/src/components/UploadZone.tsx index c49c47f..62eb5c9 100644 --- a/web/frontend/src/components/UploadZone.tsx +++ b/web/frontend/src/components/UploadZone.tsx @@ -70,9 +70,10 @@ export const UploadZone: React.FC = () => { // Fetch recommended format and update options try { + // Inside UploadZone.tsx, after fetching recommended format: const rec = await getRecommendedFormat(taskId) - // Update only the format; keep other options (e.g., transcode_to) as defaults - setOptions({ format: rec.format }) + // Update options store with container (not format) + setOptions({ container: rec.format }) } catch (err) { console.warn('Failed to fetch recommended format, using default', err) } diff --git a/web/frontend/src/stores/optionsStore.ts b/web/frontend/src/stores/optionsStore.ts index 420948a..4208d63 100644 --- a/web/frontend/src/stores/optionsStore.ts +++ b/web/frontend/src/stores/optionsStore.ts @@ -1,5 +1,3 @@ -// web/frontend/src/stores/optionsStore.ts - import { create } from 'zustand' import { SplitOptions, ContainerInfo, CodecInfo } from '../types' import { @@ -18,24 +16,16 @@ import { DEFAULT_NUMBER_TRACKS, DEFAULT_REPLACE_BAD_CHARS, DEFAULT_SKIP_EXISTING, - DEFAULT_TRANSCODE_TO, 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 -} - +// We use DEFAULT_FORMAT only as a fallback; container default is null (auto-detect) const DEFAULT_OPTIONS: SplitOptions = { - format: DEFAULT_FORMAT, - transcode_to: DEFAULT_TRANSCODE_TO ?? '', + container: null, + audio_codec: 'copy', + video_codec: 'copy', + subtitle_codec: 'copy', + video_quality: null, drop_video: DEFAULT_DROP_VIDEO, drop_subs: DEFAULT_DROP_SUBS, number_tracks: DEFAULT_NUMBER_TRACKS, @@ -53,20 +43,35 @@ const DEFAULT_OPTIONS: SplitOptions = { tracklist_format: DEFAULT_TRACKLIST_FORMAT, } +interface OptionsState { + options: SplitOptions + containers: ContainerInfo[] + codecs: CodecInfo[] + compatibility: Record + setOptions: (newOptions: Partial) => void + setContainers: (containers: ContainerInfo[]) => void + setCodecs: (codecs: CodecInfo[]) => void + setCompatibility: (compat: Record) => void + reset: () => void +} + export const useOptionsStore = create((set) => ({ options: { ...DEFAULT_OPTIONS }, containers: [], codecs: [], + compatibility: {}, setOptions: (newOptions) => set((state) => ({ options: { ...state.options, ...newOptions }, })), setContainers: (containers) => set({ containers }), setCodecs: (codecs) => set({ codecs }), + setCompatibility: (compatibility) => set({ compatibility }), reset: () => set({ options: { ...DEFAULT_OPTIONS }, containers: [], codecs: [], + compatibility: {}, }), })) diff --git a/web/frontend/src/types/index.ts b/web/frontend/src/types/index.ts index 44a5b4b..76437d2 100644 --- a/web/frontend/src/types/index.ts +++ b/web/frontend/src/types/index.ts @@ -22,8 +22,11 @@ export interface TaskStatus { } export interface SplitOptions { - format: string - transcode_to?: string + container: string | null // null = auto-detect + audio_codec: string // 'copy' or encoder name + video_codec: string // 'copy' or encoder name + subtitle_codec: string // 'copy' or encoder name + video_quality: number | null // encoder-specific integer drop_video: boolean drop_subs: boolean number_tracks: boolean @@ -38,7 +41,36 @@ export interface SplitOptions { comment_stream: number | null merge_comments: boolean comment_separator: string - tracklist_format: string + tracklist_format: string // for frontend parsing +} + +export interface ContainerInfo { + name: string + ffmpeg: string + extension: string + audio_only: boolean + supports_video: boolean + supports_subs: boolean +} + +export interface CodecInfo { + name: string + ffmpeg: string + recommended_container: string + recommended_extension: string + supports_transcoding: boolean + supports_video: boolean +} + +export interface CompatibilityEntry { + audio: string[] | null + video: string[] | null +} + +export interface FormatsResponse { + containers: ContainerInfo[] + codecs: CodecInfo[] + compatibility: Record } export interface UploadResponse { @@ -51,22 +83,3 @@ 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[] -} -- 2.52.0 From 8e0d345ba792c97c71d42dcd6ba8a4cc5128bf51 Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Fri, 28 Aug 2026 15:41:21 +0500 Subject: [PATCH 8/8] FIX: maps codecs names and ffmpeg encoders correctly --- audio_splitter/constants.py | 3 +++ audio_splitter/core.py | 24 ++++++++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/audio_splitter/constants.py b/audio_splitter/constants.py index 941c788..dae9e79 100644 --- a/audio_splitter/constants.py +++ b/audio_splitter/constants.py @@ -67,6 +67,9 @@ CODEC_INFO = [ CODEC_TO_CONTAINER_MAP = {codec['name']: codec['recommended_container'] for codec in CODEC_INFO} CODEC_TO_EXTENSION_MAP = {codec['name']: codec['recommended_extension'] for codec in CODEC_INFO} +# Map codec name → FFmpeg encoder name +CODEC_NAME_TO_FFMPEG = {codec['name']: codec['ffmpeg'] for codec in CODEC_INFO} + # ------------------------------------------------------------------------------ # Video codec support per container (legacy, will be superseded by compatibility matrix) # ------------------------------------------------------------------------------ diff --git a/audio_splitter/core.py b/audio_splitter/core.py index bfb35ef..76eeb74 100644 --- a/audio_splitter/core.py +++ b/audio_splitter/core.py @@ -6,7 +6,11 @@ import subprocess import sys from typing import List, Dict, Any -from .constants import FORMAT_INFO, CODEC_TO_EXTENSION_MAP +from .constants import ( + FORMAT_INFO, + CODEC_TO_EXTENSION_MAP, + CODEC_NAME_TO_FFMPEG, # <-- Add this +) from .ffmpeg import ( get_audio_duration, get_stream_info, @@ -192,6 +196,18 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A # ---------------------------------------------------------------------- # 8d. Build and execute FFmpeg command # ---------------------------------------------------------------------- + # Map codec names to FFmpeg encoder names + audio_enc = args.audio_codec + if audio_enc != 'copy': + audio_enc = CODEC_NAME_TO_FFMPEG.get(audio_enc, audio_enc) # fallback to itself if not found + video_enc = args.video_codec + if video_enc != 'copy': + video_enc = CODEC_NAME_TO_FFMPEG.get(video_enc, video_enc) + subtitle_enc = args.subtitle_codec + if subtitle_enc != 'copy': + subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc) + + # Then build command with these mapped encoders cmd = build_ffmpeg_command( input_file=input_file, start_seconds=start_seconds, @@ -199,9 +215,9 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A output_path=output_path, stream_info=stream_info, format_opt=output_container, - audio_codec=args.audio_codec, - video_codec=args.video_codec, - subtitle_codec=args.subtitle_codec, + audio_codec=audio_enc, + video_codec=video_enc, + subtitle_codec=subtitle_enc, metadata=metadata, cover_image_path=cover_image_path, video_quality=getattr(args, 'video_quality', None), -- 2.52.0