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, track_info: TrackInfo,
input_file: str, input_file: str,
format: str = "m4a", format: str = "m4a",
audio_bitrate: int = 160, audio_bitrate: int = 256,
overwrite: bool = False) -> str: overwrite: bool = False) -> str:
""" """
Convert audio file to iPod-compatible format Convert audio file to iPod-compatible format
@ -93,17 +93,16 @@ class AudioConverter:
# iPod Nano 7 is strict about AAC parameters: # iPod Nano 7 is strict about AAC parameters:
# - AAC-LC profile only (HE-AAC causes playback issues/crashes) # - AAC-LC profile only (HE-AAC causes playback issues/crashes)
# - 44100 Hz sample rate (firmware expects this exactly) # - 44100 Hz sample rate (firmware expects this exactly)
# - Max 160 kbps for stereo (higher bitrates cause clicks/truncation) # - +global_header: required for iPod AAC decoder init
if audio_bitrate > 160: # - +genpts: fix timestamp gaps from web sources
logger.warning("Bitrate %d kbps exceeds iPod Nano 7 max (160 kbps), clamping to 160", audio_bitrate)
audio_bitrate = 160
codec = "aac" if format == "m4a" else "libmp3lame" codec = "aac" if format == "m4a" else "libmp3lame"
cmd = [ cmd = [
"ffmpeg", "-y", "-i", input_file, "ffmpeg", "-y", "-fflags", "+genpts", "-i", input_file,
"-c:a", codec, "-c:a", codec,
"-b:a", f"{audio_bitrate}k", "-b:a", f"{audio_bitrate}k",
"-ar", "44100", "-ar", "44100",
"-ac", "2", "-ac", "2",
"-flags", "+global_header",
] ]
# For M4A/AAC: force LC profile and use libfdk_aac if available (best quality), # 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) # Try libfdk_aac first (best compatibility)
fdkaac_cmd = cmd + [ fdkaac_cmd = cmd + [
"-map", "0:a", "-map", "0:a",
"-map", "0:v?",
"-c:v", "copy",
"-map", "0:t?", "-map", "0:t?",
"-c:a", "libfdk_aac", "-c:a", "libfdk_aac",
"-profile:a", "aac_low", "-profile:a", "aac_low",
"-movflags", "+faststart", "-movflags", "+faststart",
"-map_metadata", "0", "-map_metadata", "0",
"-loglevel", "error", "-loglevel", "warning",
output_file, output_file,
] ]
try: try:
@ -131,21 +132,25 @@ class AudioConverter:
# Fallback: built-in aac with LC profile # Fallback: built-in aac with LC profile
cmd += [ cmd += [
"-map", "0:a", "-map", "0:a",
"-map", "0:v?",
"-c:v", "copy",
"-map", "0:t?", "-map", "0:t?",
"-profile:a", "aac_low", "-profile:a", "aac_low",
"-movflags", "+faststart", "-movflags", "+faststart",
"-map_metadata", "0", "-map_metadata", "0",
"-loglevel", "error", "-loglevel", "warning",
output_file, output_file,
] ]
except Exception: except Exception:
cmd += [ cmd += [
"-map", "0:a", "-map", "0:a",
"-map", "0:v?",
"-c:v", "copy",
"-map", "0:t?", "-map", "0:t?",
"-profile:a", "aac_low", "-profile:a", "aac_low",
"-movflags", "+faststart", "-movflags", "+faststart",
"-map_metadata", "0", "-map_metadata", "0",
"-loglevel", "error", "-loglevel", "warning",
output_file, output_file,
] ]
else: else:
@ -154,7 +159,7 @@ class AudioConverter:
"-map", "0:t?", "-map", "0:t?",
"-id3v2_version", "3", "-id3v2_version", "3",
"-map_metadata", "0", "-map_metadata", "0",
"-loglevel", "error", "-loglevel", "warning",
output_file, output_file,
] ]
@ -193,7 +198,7 @@ class AudioConverter:
output_file: Optional[str] = None, output_file: Optional[str] = None,
resolution: str = "640x480", resolution: str = "640x480",
video_bitrate: int = 1500, video_bitrate: int = 1500,
audio_bitrate: int = 160, audio_bitrate: int = 256,
overwrite: bool = False) -> str: overwrite: bool = False) -> str:
""" """
Convert video file to iPod-compatible format Convert video file to iPod-compatible format

View File

@ -74,8 +74,8 @@ class YouTubeToIPodCLI:
"--quality", "-q", "--quality", "-q",
help="Audio quality in kbps", help="Audio quality in kbps",
type=int, type=int,
choices=[96, 112, 128, 160], choices=[128, 192, 256, 320],
default=160 default=256
) )
# Video support # Video support
@ -128,7 +128,7 @@ class YouTubeToIPodCLI:
return parser.parse_args() 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 Download audio/video from YouTube
@ -156,7 +156,7 @@ class YouTubeToIPodCLI:
tracks: List[TrackInfo], tracks: List[TrackInfo],
output_dir: str, output_dir: str,
format: str = "m4a", format: str = "m4a",
quality: int = 160, quality: int = 256,
video: bool = False, video: bool = False,
resolution: str = "640x480") -> List[Tuple[TrackInfo, str]]: 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', 'output_dir', os.path.join(os.path.expanduser("~"), "Music", "iPod"))
self.config.set('General', 'format', 'm4a') 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') self.config.set('General', 'clean_temp', 'true')
# Video section # Video section

View File

@ -29,6 +29,7 @@ import random
import shutil import shutil
import sqlite3 import sqlite3
import struct import struct
import subprocess
import time import time
from typing import Any, Optional from typing import Any, Optional
@ -189,6 +190,38 @@ def _read_audio_tags(filepath: str) -> dict[str, Any]:
return info 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, def _scanned_tracks_to_iop(mountpoint: str,
scanned: list[dict], scanned: list[dict],
artwork_map: dict[str, tuple[int, int, int]] | None = None) -> list: 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: if artwork_map and fpath in artwork_map:
mhii_link, art_count, art_size = artwork_map[fpath] 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( t = IopTrackInfo(
title=s.get("title") or base, title=s.get("title") or base,
location=location, location=location,
size=size, size=size,
length=s.get("duration_ms", 0), length=dur_ms,
filetype=ft, filetype=ft,
bitrate=s.get("bitrate", 0) // 1000, bitrate=s.get("bitrate", 0) // 1000,
sample_rate=s.get("sample_rate", 44100), sample_rate=sr,
artist=s.get("artist") or "", artist=s.get("artist") or "",
album=s.get("album") or "", album=s.get("album") or "",
album_artist=s.get("album_artist") or s.get("artist") 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_count=art_count,
artwork_size=art_size, artwork_size=art_size,
mhii_link=mhii_link, 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) tracks.append(t)
return tracks return tracks

View File

@ -69,7 +69,7 @@ class WorkerThread(QThread):
url = self.kwargs.get("url") url = self.kwargs.get("url")
output_dir = self.kwargs.get("output_dir", "downloads") output_dir = self.kwargs.get("output_dir", "downloads")
fmt = self.kwargs.get("format", "m4a") 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}") self.progress_signal.emit(0, f"Initializing download from {url}")
@ -93,7 +93,7 @@ class WorkerThread(QThread):
local_files = self.kwargs.get("local_files", []) local_files = self.kwargs.get("local_files", [])
output_dir = self.kwargs.get("output_dir", "converted") output_dir = self.kwargs.get("output_dir", "converted")
fmt = self.kwargs.get("format", "m4a") fmt = self.kwargs.get("format", "m4a")
quality = self.kwargs.get("quality", 160) quality = self.kwargs.get("quality", 256)
valid_tracks = [ valid_tracks = [
t for t in tracks t for t in tracks
@ -314,9 +314,9 @@ class MainWindow(QMainWindow):
self.format_combo.addItems(["m4a", "mp3"]) self.format_combo.addItems(["m4a", "mp3"])
quality_label = QLabel("Quality (kbps):") quality_label = QLabel("Quality (kbps):")
self.quality_spin = QSpinBox() self.quality_spin = QSpinBox()
self.quality_spin.setRange(96, 160) self.quality_spin.setRange(128, 320)
self.quality_spin.setValue(160) self.quality_spin.setValue(256)
self.quality_spin.setSingleStep(16) self.quality_spin.setSingleStep(32)
format_layout.addWidget(format_label) format_layout.addWidget(format_label)
format_layout.addWidget(self.format_combo) format_layout.addWidget(self.format_combo)
format_layout.addWidget(quality_label) format_layout.addWidget(quality_label)

View File

@ -215,7 +215,7 @@ class YouTubeDownloader:
logger.error(f"Error extracting playlist info: {e}") logger.error(f"Error extracting playlist info: {e}")
return [] 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 Download audio from YouTube video
@ -341,7 +341,7 @@ class YouTubeDownloader:
if self.progress_callback: if self.progress_callback:
self.progress_callback(95, f"Download error: {self.current_track.title}") 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) Process a YouTube URL (video or playlist)