- New src/artwork/ module with iPod Nano 7G cover art support - codecs: RGB565/RGB555/UYVY/JPEG encode/decode via numpy - presets: 50+ iPod artwork format definitions - chunks: ArtworkDB binary parser/writer (MHFD→MHSD→MHLI→MHII→MHNI) - writer: ithmb file generation + ArtworkDB assembly - extractor: cover extraction from audio files (APIC/covr/FLAC pictures) - cache: local JPEG cache for player UI - iTunes-style playback bar with 60x60 cover art display - Scan library now caches cover art by artist+album - iPod Nano7 transfer writes artwork to ArtworkDB + ithmb - FFmpeg conversion preserves embedded cover art (-map 0:t?) - One-time reembed_covers.py script in /tmp - MetadataHandler process_file() fallback to cover_path - Bug fixes: missing read_existing_artwork import, signed PID in Q format
284 lines
11 KiB
Python
284 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 = "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()
|