From 3da5e15fe02f0df6ad15c1cfca16c2fef8c70d68 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Sun, 31 May 2026 22:13:47 +0700 Subject: [PATCH] fix: add +global_header +genpts ffmpeg flags, preserve cover art streams, gapless metadata for iPod Nano 7 --- src/audio_converter.py | 27 +++++++++++++--------- src/cli.py | 8 +++---- src/config_loader.py | 2 +- src/ipod_nano7_db.py | 48 +++++++++++++++++++++++++++++++++++++-- src/main.py | 10 ++++---- src/youtube_downloader.py | 4 ++-- 6 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/audio_converter.py b/src/audio_converter.py index 8cad08e..8ff6cf9 100644 --- a/src/audio_converter.py +++ b/src/audio_converter.py @@ -43,7 +43,7 @@ class AudioConverter: track_info: TrackInfo, input_file: str, format: str = "m4a", - audio_bitrate: int = 160, + audio_bitrate: int = 256, overwrite: bool = False) -> str: """ Convert audio file to iPod-compatible format @@ -93,17 +93,16 @@ class AudioConverter: # 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 cause clicks/truncation) - if audio_bitrate > 160: - logger.warning("Bitrate %d kbps exceeds iPod Nano 7 max (160 kbps), clamping to 160", audio_bitrate) - audio_bitrate = 160 + # - +global_header: required for iPod AAC decoder init + # - +genpts: fix timestamp gaps from web sources codec = "aac" if format == "m4a" else "libmp3lame" cmd = [ - "ffmpeg", "-y", "-i", input_file, + "ffmpeg", "-y", "-fflags", "+genpts", "-i", input_file, "-c:a", codec, "-b:a", f"{audio_bitrate}k", "-ar", "44100", "-ac", "2", + "-flags", "+global_header", ] # For M4A/AAC: force LC profile and use libfdk_aac if available (best quality), @@ -112,12 +111,14 @@ class AudioConverter: # 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", "error", + "-loglevel", "warning", output_file, ] try: @@ -131,21 +132,25 @@ class AudioConverter: # Fallback: built-in aac with LC profile cmd += [ "-map", "0:a", + "-map", "0:v?", + "-c:v", "copy", "-map", "0:t?", "-profile:a", "aac_low", "-movflags", "+faststart", "-map_metadata", "0", - "-loglevel", "error", + "-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", "error", + "-loglevel", "warning", output_file, ] else: @@ -154,7 +159,7 @@ class AudioConverter: "-map", "0:t?", "-id3v2_version", "3", "-map_metadata", "0", - "-loglevel", "error", + "-loglevel", "warning", output_file, ] @@ -193,7 +198,7 @@ class AudioConverter: output_file: Optional[str] = None, resolution: str = "640x480", video_bitrate: int = 1500, - audio_bitrate: int = 160, + audio_bitrate: int = 256, overwrite: bool = False) -> str: """ Convert video file to iPod-compatible format diff --git a/src/cli.py b/src/cli.py index 3fe1d26..dfe9b8d 100644 --- a/src/cli.py +++ b/src/cli.py @@ -74,8 +74,8 @@ class YouTubeToIPodCLI: "--quality", "-q", help="Audio quality in kbps", type=int, - choices=[96, 112, 128, 160], - default=160 + choices=[128, 192, 256, 320], + default=256 ) # Video support @@ -128,7 +128,7 @@ class YouTubeToIPodCLI: return parser.parse_args() - def download(self, url: str, format: str = "m4a", quality: int = 160, video: bool = False) -> List[TrackInfo]: + def download(self, url: str, format: str = "m4a", quality: int = 256, video: bool = False) -> List[TrackInfo]: """ Download audio/video from YouTube @@ -156,7 +156,7 @@ class YouTubeToIPodCLI: tracks: List[TrackInfo], output_dir: str, format: str = "m4a", - quality: int = 160, + quality: int = 256, video: bool = False, resolution: str = "640x480") -> List[Tuple[TrackInfo, str]]: """ diff --git a/src/config_loader.py b/src/config_loader.py index d4e51b9..96075cd 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -97,7 +97,7 @@ class ConfigLoader: self.config.set('General', 'output_dir', os.path.join(os.path.expanduser("~"), "Music", "iPod")) self.config.set('General', 'format', 'm4a') - self.config.set('General', 'quality', '160') + self.config.set('General', 'quality', '256') self.config.set('General', 'clean_temp', 'true') # Video section diff --git a/src/ipod_nano7_db.py b/src/ipod_nano7_db.py index bd25c03..761cfa5 100644 --- a/src/ipod_nano7_db.py +++ b/src/ipod_nano7_db.py @@ -29,6 +29,7 @@ import random import shutil import sqlite3 import struct +import subprocess import time from typing import Any, Optional @@ -189,6 +190,38 @@ def _read_audio_tags(filepath: str) -> dict[str, Any]: return info +def _probe_gapless(filepath: str, sample_rate: int, duration_ms: int) -> tuple[int, int, int]: + """Probe AAC/MP3 file for encoder delay and padding samples. + + Returns (pregap, postgap, sample_count). + """ + pregap = 0 + postgap = 0 + try: + result = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "a:0", + "-show_entries", "stream_tags=encoder_delay,padding", + "-of", "csv=p=0", filepath], + capture_output=True, text=True, timeout=10, + ) + if result.returncode == 0 and result.stdout.strip(): + parts = result.stdout.strip().split(",") + pregap = int(parts[0]) if parts[0] else 0 + postgap = int(parts[1]) if len(parts) > 1 and parts[1] else 0 + except Exception: + pass + + # Fallback: typical AAC-LC encoding delay (1 frame = 1024 samples at 44100 Hz) + if pregap == 0 and sample_rate == 44100: + ext = os.path.splitext(filepath)[1].lower() + if ext in (".m4a", ".aac", ".mp4"): + pregap = 1024 + + total_samples = int(duration_ms * sample_rate / 1000) if duration_ms else 0 + sample_count = max(0, total_samples - pregap - postgap) + return pregap, postgap, sample_count + + def _scanned_tracks_to_iop(mountpoint: str, scanned: list[dict], artwork_map: dict[str, tuple[int, int, int]] | None = None) -> list: @@ -210,14 +243,20 @@ def _scanned_tracks_to_iop(mountpoint: str, if artwork_map and fpath in artwork_map: mhii_link, art_count, art_size = artwork_map[fpath] + sr = s.get("sample_rate", 44100) + dur_ms = s.get("duration_ms", 0) + pregap, postgap, sample_count = _probe_gapless(fpath, sr, dur_ms) + + has_album = bool(s.get("album")) + t = IopTrackInfo( title=s.get("title") or base, location=location, size=size, - length=s.get("duration_ms", 0), + length=dur_ms, filetype=ft, bitrate=s.get("bitrate", 0) // 1000, - sample_rate=s.get("sample_rate", 44100), + sample_rate=sr, artist=s.get("artist") or "", album=s.get("album") or "", album_artist=s.get("album_artist") or s.get("artist") or "", @@ -231,6 +270,11 @@ def _scanned_tracks_to_iop(mountpoint: str, artwork_count=art_count, artwork_size=art_size, mhii_link=mhii_link, + pregap=pregap, + postgap=postgap, + sample_count=sample_count, + gapless_track_flag=1, + gapless_album_flag=1 if has_album else 0, ) tracks.append(t) return tracks diff --git a/src/main.py b/src/main.py index 49243f7..019e057 100644 --- a/src/main.py +++ b/src/main.py @@ -69,7 +69,7 @@ class WorkerThread(QThread): url = self.kwargs.get("url") output_dir = self.kwargs.get("output_dir", "downloads") fmt = self.kwargs.get("format", "m4a") - quality = self.kwargs.get("quality", 160) + quality = self.kwargs.get("quality", 256) self.progress_signal.emit(0, f"Initializing download from {url}") @@ -93,7 +93,7 @@ class WorkerThread(QThread): local_files = self.kwargs.get("local_files", []) output_dir = self.kwargs.get("output_dir", "converted") fmt = self.kwargs.get("format", "m4a") - quality = self.kwargs.get("quality", 160) + quality = self.kwargs.get("quality", 256) valid_tracks = [ t for t in tracks @@ -314,9 +314,9 @@ class MainWindow(QMainWindow): self.format_combo.addItems(["m4a", "mp3"]) quality_label = QLabel("Quality (kbps):") self.quality_spin = QSpinBox() - self.quality_spin.setRange(96, 160) - self.quality_spin.setValue(160) - self.quality_spin.setSingleStep(16) + self.quality_spin.setRange(128, 320) + self.quality_spin.setValue(256) + self.quality_spin.setSingleStep(32) format_layout.addWidget(format_label) format_layout.addWidget(self.format_combo) format_layout.addWidget(quality_label) diff --git a/src/youtube_downloader.py b/src/youtube_downloader.py index 21df87f..4c16ba8 100644 --- a/src/youtube_downloader.py +++ b/src/youtube_downloader.py @@ -215,7 +215,7 @@ class YouTubeDownloader: logger.error(f"Error extracting playlist info: {e}") return [] - def download_audio(self, track_info: TrackInfo, format: str = "m4a", quality: int = 160) -> Optional[str]: + def download_audio(self, track_info: TrackInfo, format: str = "m4a", quality: int = 256) -> Optional[str]: """ Download audio from YouTube video @@ -341,7 +341,7 @@ class YouTubeDownloader: if self.progress_callback: self.progress_callback(95, f"Download error: {self.current_track.title}") - def process_url(self, url: str, format: str = "m4a", quality: int = 160) -> List[TrackInfo]: + def process_url(self, url: str, format: str = "m4a", quality: int = 256) -> List[TrackInfo]: """ Process a YouTube URL (video or playlist)