neo-pod-desktop/src/audio_converter.py

303 lines
11 KiB
Python

#!/usr/bin/env python3
"""
Audio Converter Module for iPod Nano Transfer Tool
Handles converting audio files to iPod-compatible formats
"""
import os
import logging
import subprocess
from typing import Optional, Dict, Any
from pathlib import Path
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"""
def __init__(self, output_dir: str = "converted"):
"""
Initialize the audio converter
Args:
output_dir: Directory to save converted files
"""
self.output_dir = output_dir
os.makedirs(output_dir, exist_ok=True)
# Check if ffmpeg is installed
try:
subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)
except (subprocess.SubprocessError, FileNotFoundError):
logger.error("FFmpeg is not installed or not in PATH. Please install FFmpeg.")
raise RuntimeError("FFmpeg is required but not found")
def convert_to_ipod_format(self,
track_info: TrackInfo,
input_file: str,
format: str = "mp3",
audio_bitrate: int = 256,
overwrite: bool = False) -> str:
"""
Convert audio file to iPod-compatible format
Args:
track_info: TrackInfo object with track metadata
input_file: Path to input audio file
format: Output format (m4a, mp3)
audio_bitrate: Audio bitrate in kbps
overwrite: Whether to overwrite existing files
Returns:
Path to converted file
"""
logger.info(f"Converting {input_file} to iPod-compatible {format}")
# Create output directory structure
if track_info.playlist_title:
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)
if track_info.track_number:
filename = f"{track_info.track_number:02d} - {self._sanitize_filename(track_info.title)}.{format}"
else:
filename = f"{self._sanitize_filename(track_info.title)}.{format}"
output_file = os.path.join(album_dir, filename)
else:
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)
filename = f"{self._sanitize_filename(track_info.title)}.{format}"
output_file = os.path.join(album_dir, filename)
# Check if file already exists — but validate it's not empty/corrupt
if os.path.exists(output_file) and not overwrite:
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)
if format == "m4a":
# iPod Nano 7 AAC requirements:
# - AAC-LC profile only (HE-AAC causes playback issues/crashes)
# - 44100 Hz sample rate (firmware expects this exactly)
# - Max 160 kbps for stereo (higher may cause truncation/crackle)
# - +global_header: required for iPod AAC decoder init
# - +genpts: fix timestamp gaps from web sources
m4a_bitrate = min(audio_bitrate, 160)
if audio_bitrate > 160:
logger.warning(
"Bitrate %d kbps exceeds iPod Nano 7 max (160 kbps), clamping to 160",
audio_bitrate,
)
cmd = [
"ffmpeg", "-y", "-fflags", "+genpts", "-i", input_file,
"-c:a", "aac",
"-b:a", f"{m4a_bitrate}k",
"-ar", "44100",
"-ac", "2",
"-flags", "+global_header",
]
# Try libfdk_aac first (best compatibility)
fdkaac_cmd = cmd + [
"-map", "0:a",
"-map", "0:v?",
"-c:v", "copy",
"-map", "0:t?",
"-c:a", "libfdk_aac",
"-profile:a", "aac_low",
"-movflags", "+faststart",
"-map_metadata", "0",
"-loglevel", "warning",
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:
cmd += [
"-map", "0:a",
"-map", "0:v?",
"-c:v", "copy",
"-map", "0:t?",
"-profile:a", "aac_low",
"-movflags", "+faststart",
"-map_metadata", "0",
"-loglevel", "warning",
output_file,
]
except Exception:
cmd += [
"-map", "0:a",
"-map", "0:v?",
"-c:v", "copy",
"-map", "0:t?",
"-profile:a", "aac_low",
"-movflags", "+faststart",
"-map_metadata", "0",
"-loglevel", "warning",
output_file,
]
else:
# MP3 for iPod Nano 7G: reliable, no special decoder flags needed.
# libmp3lame is mature and produces streams safe for hardware decoders.
# No +global_header — that flag is AAC-only and confuses MP3 playback.
cmd = [
"ffmpeg", "-y", "-i", input_file,
"-c:a", "libmp3lame",
"-b:a", f"{audio_bitrate}k",
"-ar", "44100",
"-ac", "2",
"-bitexact",
"-map", "0:a",
"-map", "0:t?",
"-id3v2_version", "3",
"-map_metadata", "0",
"-loglevel", "warning",
output_file,
]
logger.debug(f"Running: {' '.join(cmd)}")
try:
result = subprocess.run(
cmd,
capture_output=True,
timeout=CONVERSION_TIMEOUT,
text=True
)
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 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,
output_file: Optional[str] = None,
resolution: str = "640x480",
video_bitrate: int = 1500,
audio_bitrate: int = 256,
overwrite: bool = False) -> str:
"""
Convert video file to iPod-compatible format
Args:
input_file: Path to input video file
output_file: Path to output file (optional)
resolution: Video resolution (max 640x480 for iPod Nano)
video_bitrate: Video bitrate in kbps
audio_bitrate: Audio bitrate in kbps
overwrite: Whether to overwrite existing files
Returns:
Path to converted file
"""
logger.info(f"Converting video {input_file} to iPod-compatible format")
if not output_file:
input_path = Path(input_file)
output_file = os.path.join(self.output_dir, f"{input_path.stem}_ipod.mp4")
# Check if file already exists
if os.path.exists(output_file) and not overwrite:
logger.info(f"File already exists: {output_file}")
return output_file
# 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:
result = subprocess.run(
cmd,
capture_output=True,
timeout=CONVERSION_TIMEOUT * 3, # Video takes longer
text=True
)
if result.returncode != 0:
stderr = result.stderr.strip()
logger.error(f"FFmpeg failed: {stderr}")
raise RuntimeError(f"FFmpeg video conversion failed: {stderr}")
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 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:
"""
Sanitize filename to be compatible with file systems
Args:
filename: Input filename
Returns:
Sanitized filename
"""
# Replace invalid characters with underscore
invalid_chars = '<>:"/\\|?*'
for char in invalid_chars:
filename = filename.replace(char, '_')
# Limit length
if len(filename) > 100:
filename = filename[:97] + '...'
return filename.strip()
if __name__ == "__main__":
# Example usage
converter = AudioConverter()