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
This commit is contained in:
Maksim Totmin 2026-05-31 14:38:48 +07:00
parent 47bfdf0011
commit 381bf106ed

View File

@ -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")