#!/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 = "m4a", 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) # 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", "-map", "0:t?", "-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", "-map", "0:t?", "-profile:a", "aac_low", "-movflags", "+faststart", "-map_metadata", "0", "-loglevel", "error", output_file, ] except Exception: cmd += [ "-map", "0:a", "-map", "0:t?", "-profile:a", "aac_low", "-movflags", "+faststart", "-map_metadata", "0", "-loglevel", "error", output_file, ] else: cmd += [ "-map", "0:a", "-map", "0:t?", "-id3v2_version", "3", "-map_metadata", "0", "-loglevel", "error", 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()