From 381bf106edbeb868e4cdaa6246deb1f32391351e Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Sun, 31 May 2026 14:38:48 +0700 Subject: [PATCH] Fix audio conversion: replace ffmpeg-python with subprocess, add AAC-LC profile and faststart - Replace ffmpeg-python with subprocess.run + timeout (prevents hangs) - Add -ar 44100 -ac 2 for iPod Nano 7 compatibility - Add -profile:a aac_low and -movflags +faststart (moov atom at start) - Add -map 0:a to skip embedded cover art streams in FLAC files - Validate output file size, reject empty/corrupt conversions --- src/audio_converter.py | 182 ++++++++++++++++++++++++++++------------- 1 file changed, 124 insertions(+), 58 deletions(-) diff --git a/src/audio_converter.py b/src/audio_converter.py index d92fb80..bea3ef2 100644 --- a/src/audio_converter.py +++ b/src/audio_converter.py @@ -10,14 +10,14 @@ import subprocess from typing import Optional, Dict, Any from pathlib import Path -import ffmpeg - from youtube_downloader import TrackInfo # Configure logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) +CONVERSION_TIMEOUT = 300 # 5 minutes max per track + class AudioConverter: """Class to handle audio conversion to iPod-compatible formats""" @@ -62,7 +62,6 @@ class AudioConverter: # Create output directory structure if track_info.playlist_title: - # For playlist tracks: Artist/Playlist/01 - Track.m4a artist_dir = os.path.join(self.output_dir, self._sanitize_filename(track_info.artist)) album_dir = os.path.join(artist_dir, self._sanitize_filename(track_info.playlist_title)) os.makedirs(album_dir, exist_ok=True) @@ -74,7 +73,6 @@ class AudioConverter: output_file = os.path.join(album_dir, filename) else: - # For single tracks: Artist/Artist/Track.m4a artist_dir = os.path.join(self.output_dir, self._sanitize_filename(track_info.artist)) album_dir = os.path.join(artist_dir, self._sanitize_filename(track_info.album or track_info.artist)) os.makedirs(album_dir, exist_ok=True) @@ -82,33 +80,105 @@ class AudioConverter: filename = f"{self._sanitize_filename(track_info.title)}.{format}" output_file = os.path.join(album_dir, filename) - # Check if file already exists + # Check if file already exists — but validate it's not empty/corrupt if os.path.exists(output_file) and not overwrite: - logger.info(f"File already exists: {output_file}") - return output_file + file_size = os.path.getsize(output_file) + if file_size > 1024: # Minimum reasonable size for audio + logger.info(f"File already exists: {output_file} ({file_size} bytes)") + return output_file + else: + logger.warning(f"Existing file is too small ({file_size} bytes), re-converting") + os.remove(output_file) + + # Build ffmpeg command — iPod Nano 7 is strict about AAC parameters: + # - AAC-LC profile only (HE-AAC causes playback issues/crashes) + # - 44100 Hz sample rate (firmware expects this exactly) + # - Max 160 kbps for stereo (higher bitrates may truncate playback) + codec = "aac" if format == "m4a" else "libmp3lame" + cmd = [ + "ffmpeg", "-y", "-i", input_file, + "-c:a", codec, + "-b:a", f"{audio_bitrate}k", + "-ar", "44100", + "-ac", "2", + ] + + # For M4A/AAC: force LC profile and use libfdk_aac if available (best quality), + # otherwise fall back to built-in aac with profile flag + if format == "m4a": + # Try libfdk_aac first (best compatibility) + fdkaac_cmd = cmd + [ + "-map", "0:a", + "-c:a", "libfdk_aac", + "-profile:a", "aac_low", + "-movflags", "+faststart", + "-map_metadata", "0", + "-loglevel", "error", + output_file, + ] + try: + test = subprocess.run( + ["ffmpeg", "-hide_banner", "-encoders"], + capture_output=True, text=True, timeout=5 + ) + if "libfdk_aac" in test.stdout: + cmd = fdkaac_cmd + else: + # Fallback: built-in aac with LC profile + cmd += [ + "-map", "0:a", + "-profile:a", "aac_low", + "-movflags", "+faststart", + "-map_metadata", "0", + "-loglevel", "error", + output_file, + ] + except Exception: + cmd += [ + "-map", "0:a", + "-profile:a", "aac_low", + "-movflags", "+faststart", + "-map_metadata", "0", + "-loglevel", "error", + output_file, + ] + else: + cmd += [ + "-map", "0:a", + "-map_metadata", "0", + "-loglevel", "error", + output_file, + ] + + logger.debug(f"Running: {' '.join(cmd)}") - # Set up FFmpeg conversion try: - # Input file - stream = ffmpeg.input(input_file) + result = subprocess.run( + cmd, + capture_output=True, + timeout=CONVERSION_TIMEOUT, + text=True + ) - # Output options - audio_options: Dict[str, Any] = { - 'acodec': 'aac' if format == 'm4a' else 'libmp3lame', - 'b:a': f'{audio_bitrate}k', - 'map_metadata': 0, - } + if result.returncode != 0: + stderr = result.stderr.strip() + logger.error(f"FFmpeg failed: {stderr}") + raise RuntimeError(f"FFmpeg conversion failed: {stderr}") - # Run conversion - stream = ffmpeg.output(stream, output_file, **audio_options) - ffmpeg.run(stream, overwrite_output=overwrite, quiet=True) + if not os.path.exists(output_file): + raise RuntimeError("FFmpeg completed but output file not found") - logger.info(f"Conversion complete: {output_file}") + file_size = os.path.getsize(output_file) + if file_size < 1024: + os.remove(output_file) + raise RuntimeError(f"FFmpeg produced empty/corrupt file ({file_size} bytes)") + + logger.info(f"Conversion complete: {output_file} ({file_size} bytes)") return output_file - except ffmpeg.Error as e: - logger.error(f"FFmpeg error: {e.stderr.decode() if e.stderr else str(e)}") - raise + except subprocess.TimeoutExpired: + logger.error(f"FFmpeg timed out after {CONVERSION_TIMEOUT}s on {input_file}") + raise RuntimeError(f"Conversion timed out after {CONVERSION_TIMEOUT}s") def convert_video_for_ipod(self, input_file: str, @@ -142,32 +212,44 @@ class AudioConverter: logger.info(f"File already exists: {output_file}") return output_file - # Set up FFmpeg conversion + # Build ffmpeg command + cmd = [ + "ffmpeg", "-y", "-i", input_file, + "-c:v", "h264", + "-b:v", f"{video_bitrate}k", + "-c:a", "aac", + "-b:a", f"{audio_bitrate}k", + "-vf", f"scale={resolution}", + "-pix_fmt", "yuv420p", + "-map_metadata", "0", + "-loglevel", "error", + output_file, + ] + + logger.debug(f"Running: {' '.join(cmd)}") + try: - # Input file - stream = ffmpeg.input(input_file) + result = subprocess.run( + cmd, + capture_output=True, + timeout=CONVERSION_TIMEOUT * 3, # Video takes longer + text=True + ) - # Output options - video_options = { - 'vcodec': 'h264', - 'b:v': f'{video_bitrate}k', - 'acodec': 'aac', - 'b:a': f'{audio_bitrate}k', - 'vf': f'scale={resolution}', - 'pix_fmt': 'yuv420p', # Required for compatibility - 'map_metadata': 0, - } + if result.returncode != 0: + stderr = result.stderr.strip() + logger.error(f"FFmpeg failed: {stderr}") + raise RuntimeError(f"FFmpeg video conversion failed: {stderr}") - # Run conversion - stream = ffmpeg.output(stream, output_file, **video_options) - ffmpeg.run(stream, overwrite_output=overwrite, quiet=True) + if not os.path.exists(output_file): + raise RuntimeError("FFmpeg completed but output file not found") logger.info(f"Video conversion complete: {output_file}") return output_file - except ffmpeg.Error as e: - logger.error(f"FFmpeg error: {e.stderr.decode() if e.stderr else str(e)}") - raise + except subprocess.TimeoutExpired: + logger.error(f"FFmpeg timed out after {CONVERSION_TIMEOUT * 3}s on {input_file}") + raise RuntimeError(f"Video conversion timed out") def _sanitize_filename(self, filename: str) -> str: """ @@ -194,19 +276,3 @@ class AudioConverter: if __name__ == "__main__": # Example usage converter = AudioConverter() - - # Test audio conversion - # converter.convert_to_ipod_format( - # TrackInfo( - # video_id="dQw4w9WgXcQ", - # title="Never Gonna Give You Up", - # artist="Rick Astley", - # album="Whenever You Need Somebody", - # thumbnail_url="", - # duration=213 - # ), - # "input.mp3" - # ) - - # Test video conversion - # converter.convert_video_for_ipod("input.mp4")