fix: add +global_header +genpts ffmpeg flags, preserve cover art streams, gapless metadata for iPod Nano 7
This commit is contained in:
parent
71928d3478
commit
3da5e15fe0
@ -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
|
||||||
|
|||||||
@ -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]]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
10
src/main.py
10
src/main.py
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user