neo-pod-desktop/src/audio_converter.py
Maksim Totmin 489d99c068 Add cover art pipeline (extract/encode/cache) and iTunes-style player
- 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
2026-05-31 17:45:01 +07:00

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()