From 580837fa73cdab1dc7f6efac6170cfb6cc5e35df Mon Sep 17 00:00:00 2001 From: Maxim Vershinin Date: Mon, 31 Aug 2026 14:19:07 +0500 Subject: [PATCH] feature (core): adds special handlers for audio+cover_image cases --- audio_splitter/core.py | 33 +++++++- audio_splitter/ffmpeg.py | 3 + audio_splitter/handlers.py | 167 +++++++++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 audio_splitter/handlers.py diff --git a/audio_splitter/core.py b/audio_splitter/core.py index 76eeb74..cbe4c8b 100644 --- a/audio_splitter/core.py +++ b/audio_splitter/core.py @@ -23,6 +23,7 @@ from .formats import ( determine_output_format, validate_format_compatibility, ) +from .handlers import get_handler, needs_drop_video from .timestamp import parse_track_timestamps, resolve_end_times from .filename import build_filename from .metadata import build_metadata_dict @@ -162,6 +163,14 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A else: cover_image_path = None + # Check if we need special handling (e.g., Opus files need opustags) + input_audio_codec = stream_info.get('audio_codec') + cover_handler = get_handler(output_container, input_audio_codec) + needs_drop = needs_drop_video(output_container, input_audio_codec) + if cover_image_path and needs_drop: + print(f"Using special handler for {output_container} + {input_audio_codec}") + print("Temporarily dropping video for opustags post-processing.") + # -------------------------------------------------------------------------- # 8. Process each track # -------------------------------------------------------------------------- @@ -208,6 +217,11 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A subtitle_enc = CODEC_NAME_TO_FFMPEG.get(subtitle_enc, subtitle_enc) # Then build command with these mapped encoders + # Use temporary drop_video for handlers that need it + effective_drop_video = args.drop_video or (cover_image_path and needs_drop) + # For handlers that need opustags post-processing, don't pass cover_image_path + # to ffmpeg - it will process audio-only, then handler adds cover afterward + ffmpeg_cover_path = None if (cover_image_path and needs_drop) else cover_image_path cmd = build_ffmpeg_command( input_file=input_file, start_seconds=start_seconds, @@ -219,9 +233,9 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A video_codec=video_enc, subtitle_codec=subtitle_enc, metadata=metadata, - cover_image_path=cover_image_path, + cover_image_path=ffmpeg_cover_path, video_quality=getattr(args, 'video_quality', None), - drop_video=args.drop_video, + drop_video=effective_drop_video, drop_subs=args.drop_subs, ) @@ -235,6 +249,21 @@ def split_audio(input_file: str, output_directory: str, tracks: List[Dict[str, A print(result.stderr) else: print(f" -> Saved to: {output_path}") + # Apply cover image handler if needed + if cover_image_path and not args.drop_video and needs_drop: + print(f"Applying cover image via {output_container} handler...") + if not cover_handler( + input_file=input_file, + cover_image_path=cover_image_path, + output_path=output_path, + stream_info=stream_info, + audio_codec=audio_enc, + video_codec=video_enc, + video_quality=getattr(args, 'video_quality', None), + drop_video=args.drop_video, + drop_subs=args.drop_subs, + ): + print(f"Warning: Failed to attach cover image to {output_path}") # -------------------------------------------------------------------------- # 9. Delete original file if requested diff --git a/audio_splitter/ffmpeg.py b/audio_splitter/ffmpeg.py index 4471f58..2090bf2 100644 --- a/audio_splitter/ffmpeg.py +++ b/audio_splitter/ffmpeg.py @@ -310,6 +310,9 @@ def _build_cover_image_mapping( else: cmd.extend(['-c:a', 'copy']) + # Preserve attached_pic disposition for cover image + cmd.extend(['-disposition', 'attached_pic']) + cmd.append('-sn') if video_quality is not None: diff --git a/audio_splitter/handlers.py b/audio_splitter/handlers.py new file mode 100644 index 0000000..1a6e8f2 --- /dev/null +++ b/audio_splitter/handlers.py @@ -0,0 +1,167 @@ +# audio_splitter/handlers.py +"""Handler system for cover image attachment. + +This module provides a registry of handlers for different +codec + image combinations. Each handler is responsible for +attaching the cover image to the output file in the appropriate way. +""" + +import os +import subprocess +from typing import Dict, Optional + + +def _run_opustags(input_file: str, cover_image_path: str, output_path: Optional[str] = None) -> bool: + """ + Run opustags to attach a cover image to an Opus file. + + Args: + input_file: Path to the input Opus file. + cover_image_path: Path to the cover image. + output_path: Optional output path (if None, overwrites input). + + Returns: + True if successful, False otherwise. + """ + cmd = ['opustags', '--set-cover', cover_image_path, input_file] + + if output_path: + cmd.extend(['-o', output_path, '-y']) + else: + cmd.extend(['-i']) + + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + print(f"opustags failed: {result.stderr}") + return False + return True + + +def _handler_default( + input_file: str, + cover_image_path: str, + output_path: str, + stream_info: Dict, + audio_codec: str, + video_codec: str, + video_quality: Optional[int], + drop_video: bool, + drop_subs: bool, +) -> bool: + """ + Default handler: use ffmpeg with -disposition attached_pic. + + This is the standard approach for formats like MP3, FLAC, etc. + """ + # This handler doesn't do anything - the cover is already handled + # by the ffmpeg command in core.py via build_ffmpeg_command() + # We just return True to indicate success. + return True + + +def _handler_opus_with_cover( + input_file: str, + cover_image_path: str, + output_path: str, + stream_info: Dict, + audio_codec: str, + video_codec: str, + video_quality: Optional[int], + drop_video: bool, + drop_subs: bool, +) -> bool: + """ + Handler for Opus files with cover image. + + Uses opustags to attach the cover image after the file is processed. + """ + return _run_opustags(output_path, cover_image_path) + + +def _handler_mp3_with_cover( + input_file: str, + cover_image_path: str, + output_path: str, + stream_info: Dict, + audio_codec: str, + video_codec: str, + video_quality: Optional[int], + drop_video: bool, + drop_subs: bool, +) -> bool: + """ + Handler for MP3 files with cover image. + + Uses ffmpeg with -disposition attached_pic. + """ + # MP3 handler - the cover is already attached by ffmpeg in core.py + return True + + +def _handler_flac_with_cover( + input_file: str, + cover_image_path: str, + output_path: str, + stream_info: Dict, + audio_codec: str, + video_codec: str, + video_quality: Optional[int], + drop_video: bool, + drop_subs: bool, +) -> bool: + """ + Handler for FLAC files with cover image. + + Uses ffmpeg with -disposition attached_pic. + """ + # FLAC handler - the cover is already attached by ffmpeg in core.py + return True + + +# Handler registry: maps (output_container, input_codec) to handler function +HANDLER_REGISTRY: Dict[tuple, callable] = { + ('opus', 'opus'): _handler_opus_with_cover, + ('ogg', 'opus'): _handler_opus_with_cover, + ('mp3', 'mp3'): _handler_mp3_with_cover, + ('flac', 'flac'): _handler_flac_with_cover, +} + + +def get_handler(output_container: str, input_audio_codec: Optional[str] = None): + """ + Get the appropriate handler for the given container and input codec. + + Args: + output_container: Output container format (e.g., 'opus', 'mp3', 'flac'). + input_audio_codec: Input audio codec (e.g., 'opus', 'mp3', 'flac'). + + Returns: + Handler function, or the default handler if no specific handler is found. + """ + if input_audio_codec: + key = (output_container, input_audio_codec) + if key in HANDLER_REGISTRY: + return HANDLER_REGISTRY[key] + + # Fall back to container-only key + if (output_container, None) in HANDLER_REGISTRY: + return HANDLER_REGISTRY[(output_container, None)] + + # Default handler + return _handler_default + + +def needs_drop_video(output_container: str, input_audio_codec: Optional[str] = None) -> bool: + """ + Check if the handler requires dropping video even if user didn't specify --drop-video. + + Args: + output_container: Output container format. + input_audio_codec: Input audio codec. + + Returns: + True if video should be dropped, False otherwise. + """ + handler = get_handler(output_container, input_audio_codec) + # The opus handler requires dropping video + return handler == _handler_opus_with_cover \ No newline at end of file