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:
parent
47bfdf0011
commit
381bf106ed
@ -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}")
|
||||
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)
|
||||
|
||||
# Set up FFmpeg conversion
|
||||
# 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:
|
||||
# Input file
|
||||
stream = ffmpeg.input(input_file)
|
||||
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,
|
||||
]
|
||||
|
||||
# Output options
|
||||
audio_options: Dict[str, Any] = {
|
||||
'acodec': 'aac' if format == 'm4a' else 'libmp3lame',
|
||||
'b:a': f'{audio_bitrate}k',
|
||||
'map_metadata': 0,
|
||||
}
|
||||
logger.debug(f"Running: {' '.join(cmd)}")
|
||||
|
||||
# Run conversion
|
||||
stream = ffmpeg.output(stream, output_file, **audio_options)
|
||||
ffmpeg.run(stream, overwrite_output=overwrite, quiet=True)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
timeout=CONVERSION_TIMEOUT,
|
||||
text=True
|
||||
)
|
||||
|
||||
logger.info(f"Conversion complete: {output_file}")
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
logger.error(f"FFmpeg failed: {stderr}")
|
||||
raise RuntimeError(f"FFmpeg conversion failed: {stderr}")
|
||||
|
||||
if not os.path.exists(output_file):
|
||||
raise RuntimeError("FFmpeg completed but output file not found")
|
||||
|
||||
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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user