fix: add +global_header +genpts ffmpeg flags, preserve cover art streams, gapless metadata for iPod Nano 7

This commit is contained in:
Maksim Totmin 2026-05-31 22:13:47 +07:00
parent 71928d3478
commit 3da5e15fe0
6 changed files with 74 additions and 25 deletions

View File

@ -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

View File

@ -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]]:
"""

View File

@ -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

View File

@ -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

View File

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

View File

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