2026-06-21 04:03:28 +08:00
|
|
|
|
"""网易云音乐下载器(适配 ffmpeg 写入元数据)
|
2025-08-25 01:23:41 +08:00
|
|
|
|
|
2026-06-21 04:03:28 +08:00
|
|
|
|
提供功能:
|
|
|
|
|
|
- 同步 / 异步下载(本地落盘 / 内存返回)
|
|
|
|
|
|
- 批量并发下载
|
|
|
|
|
|
- 进度查询
|
|
|
|
|
|
- 基于 ffmpeg 的元数据 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus)
|
|
|
|
|
|
|
|
|
|
|
|
设计要点:
|
|
|
|
|
|
1. ffmpeg 路径懒加载:每次写入前重新检测,支持服务启动后中途安装
|
|
|
|
|
|
2. 显式 -f 指定输出格式 + 临时文件保留原扩展名在最后,双保险
|
|
|
|
|
|
3. MP4(杜比全景声 EAC3)单独分支,启用 +faststart
|
|
|
|
|
|
4. 扩展名优先使用 API 返回的 type 字段,URL/Content-Type 兜底
|
2025-08-25 01:23:41 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-06-21 04:03:28 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2025-08-25 01:23:41 +08:00
|
|
|
|
import os
|
|
|
|
|
|
import re
|
2026-06-21 04:03:28 +08:00
|
|
|
|
import shutil
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import tempfile
|
2025-08-25 01:23:41 +08:00
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from enum import Enum
|
2026-06-21 04:03:28 +08:00
|
|
|
|
from io import BytesIO
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
2025-08-25 01:23:41 +08:00
|
|
|
|
|
2026-06-21 04:03:28 +08:00
|
|
|
|
import aiofiles
|
|
|
|
|
|
import aiohttp
|
2025-08-25 01:23:41 +08:00
|
|
|
|
import requests
|
|
|
|
|
|
|
2026-06-21 04:03:28 +08:00
|
|
|
|
from music_api import APIException, NeteaseAPI, load_cookies
|
2025-08-25 01:23:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 04:03:28 +08:00
|
|
|
|
# ============ 类型定义 ============
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
class AudioFormat(Enum):
|
|
|
|
|
|
"""音频格式枚举"""
|
|
|
|
|
|
MP3 = "mp3"
|
|
|
|
|
|
FLAC = "flac"
|
|
|
|
|
|
M4A = "m4a"
|
2026-06-21 04:03:28 +08:00
|
|
|
|
MP4 = "mp4"
|
2025-08-25 01:23:41 +08:00
|
|
|
|
UNKNOWN = "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class QualityLevel(Enum):
|
|
|
|
|
|
"""音质等级枚举"""
|
2026-06-21 04:03:28 +08:00
|
|
|
|
STANDARD = "standard"
|
|
|
|
|
|
EXHIGH = "exhigh"
|
|
|
|
|
|
LOSSLESS = "lossless"
|
|
|
|
|
|
HIRES = "hires"
|
|
|
|
|
|
SKY = "sky"
|
|
|
|
|
|
JYEFFECT = "jyeffect"
|
|
|
|
|
|
JYMASTER = "jymaster"
|
|
|
|
|
|
DOLBY = "dolby"
|
2025-08-25 01:23:41 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class MusicInfo:
|
|
|
|
|
|
"""音乐信息数据类"""
|
|
|
|
|
|
id: int
|
|
|
|
|
|
name: str
|
|
|
|
|
|
artists: str
|
|
|
|
|
|
album: str
|
|
|
|
|
|
pic_url: str
|
|
|
|
|
|
duration: int
|
|
|
|
|
|
track_number: int
|
|
|
|
|
|
download_url: str
|
|
|
|
|
|
file_type: str
|
|
|
|
|
|
file_size: int
|
|
|
|
|
|
quality: str
|
|
|
|
|
|
lyric: str = ""
|
|
|
|
|
|
tlyric: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class DownloadResult:
|
|
|
|
|
|
"""下载结果数据类"""
|
|
|
|
|
|
success: bool
|
|
|
|
|
|
file_path: Optional[str] = None
|
|
|
|
|
|
file_size: int = 0
|
|
|
|
|
|
error_message: str = ""
|
|
|
|
|
|
music_info: Optional[MusicInfo] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DownloadException(Exception):
|
|
|
|
|
|
"""下载异常类"""
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-21 04:03:28 +08:00
|
|
|
|
# ============ 工具映射表 ============
|
|
|
|
|
|
|
|
|
|
|
|
# API 返回的 type -> 文件扩展名
|
|
|
|
|
|
API_TYPE_TO_EXT = {
|
|
|
|
|
|
"mp3": ".mp3",
|
|
|
|
|
|
"flac": ".flac",
|
|
|
|
|
|
"m4a": ".m4a",
|
|
|
|
|
|
"mp4": ".mp4",
|
|
|
|
|
|
"ogg": ".ogg",
|
|
|
|
|
|
"opus": ".opus",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 文件扩展名 -> ffmpeg 输出 muxer 名称
|
|
|
|
|
|
EXT_TO_FFMPEG_FORMAT = {
|
|
|
|
|
|
".mp3": "mp3",
|
|
|
|
|
|
".flac": "flac",
|
|
|
|
|
|
".m4a": "mp4",
|
|
|
|
|
|
".mp4": "mp4",
|
|
|
|
|
|
".ogg": "ogg",
|
|
|
|
|
|
".opus": "opus",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 支持元数据写入的扩展名
|
|
|
|
|
|
METADATA_FORMATS = {".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".opus"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============ 核心实现 ============
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
class MusicDownloader:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
"""网易云音乐下载器
|
|
|
|
|
|
|
|
|
|
|
|
用法:
|
|
|
|
|
|
downloader = MusicDownloader(download_dir="downloads")
|
|
|
|
|
|
result = downloader.download_music_file(music_id, quality="lossless")
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
|
|
self,
|
|
|
|
|
|
download_dir: str = "downloads",
|
|
|
|
|
|
max_concurrent: int = 3,
|
|
|
|
|
|
ffmpeg_path: Optional[str] = None,
|
|
|
|
|
|
):
|
2025-08-25 01:23:41 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Args:
|
|
|
|
|
|
download_dir: 下载目录
|
|
|
|
|
|
max_concurrent: 最大并发下载数
|
2026-06-21 04:03:28 +08:00
|
|
|
|
ffmpeg_path: ffmpeg 可执行文件路径,为 None 时启动时检测一次
|
2025-08-25 01:23:41 +08:00
|
|
|
|
"""
|
|
|
|
|
|
self.download_dir = Path(download_dir)
|
|
|
|
|
|
self.download_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
self.max_concurrent = max_concurrent
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
self.api = NeteaseAPI()
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
# ffmpeg 路径缓存:启动时检测一次,写入时还会再懒加载一次
|
|
|
|
|
|
self.ffmpeg_path = ffmpeg_path or shutil.which("ffmpeg")
|
|
|
|
|
|
if not self.ffmpeg_path:
|
|
|
|
|
|
print("警告: 未检测到 ffmpeg,写入元数据功能将不可用")
|
|
|
|
|
|
|
|
|
|
|
|
# ----------- 工具方法 -----------
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
def _sanitize_filename(self, filename: str) -> str:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
"""清理文件名非法字符"""
|
|
|
|
|
|
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
2025-08-25 01:23:41 +08:00
|
|
|
|
filename = filename.strip(' .')
|
|
|
|
|
|
if len(filename) > 200:
|
|
|
|
|
|
filename = filename[:200]
|
|
|
|
|
|
return filename or "unknown"
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
def _determine_file_extension(
|
|
|
|
|
|
self,
|
|
|
|
|
|
url: str,
|
|
|
|
|
|
content_type: str = "",
|
|
|
|
|
|
api_type: str = "",
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""确定文件扩展名
|
|
|
|
|
|
|
|
|
|
|
|
优先级: API 返回的 type > URL 后缀 > HTTP Content-Type > 默认 mp3
|
2025-08-25 01:23:41 +08:00
|
|
|
|
"""
|
2026-06-21 04:03:28 +08:00
|
|
|
|
api_type = (api_type or "").lower()
|
|
|
|
|
|
if api_type in API_TYPE_TO_EXT:
|
|
|
|
|
|
return API_TYPE_TO_EXT[api_type]
|
|
|
|
|
|
|
|
|
|
|
|
url_lower = url.lower()
|
|
|
|
|
|
if ".flac" in url_lower:
|
|
|
|
|
|
return ".flac"
|
|
|
|
|
|
if ".mp3" in url_lower:
|
|
|
|
|
|
return ".mp3"
|
|
|
|
|
|
if ".m4a" in url_lower or ".mp4" in url_lower:
|
|
|
|
|
|
return ".m4a"
|
|
|
|
|
|
|
|
|
|
|
|
content_type = (content_type or "").lower()
|
|
|
|
|
|
if "flac" in content_type:
|
|
|
|
|
|
return ".flac"
|
|
|
|
|
|
if "mpeg" in content_type or "mp3" in content_type:
|
|
|
|
|
|
return ".mp3"
|
|
|
|
|
|
if "mp4" in content_type or "m4a" in content_type:
|
|
|
|
|
|
return ".m4a"
|
|
|
|
|
|
|
|
|
|
|
|
return ".mp3"
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_ffmpeg(self) -> Optional[str]:
|
|
|
|
|
|
"""懒加载解析 ffmpeg 路径
|
|
|
|
|
|
|
|
|
|
|
|
每次写入前调用一次:若启动时未检测到,但中途已安装,下一次就能用。
|
|
|
|
|
|
"""
|
|
|
|
|
|
if self.ffmpeg_path:
|
|
|
|
|
|
return self.ffmpeg_path
|
|
|
|
|
|
path = shutil.which("ffmpeg")
|
|
|
|
|
|
if path:
|
|
|
|
|
|
self.ffmpeg_path = path # 回写缓存
|
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
# ----------- 信息获取 -----------
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
def get_music_info(self, music_id: int, quality: str = "standard") -> MusicInfo:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
"""获取音乐详细信息(同步)"""
|
2025-08-25 01:23:41 +08:00
|
|
|
|
try:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
cookies = load_cookies()
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
url_result = self.api.get_song_url(music_id, quality, cookies)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
print(url_result)
|
|
|
|
|
|
if not url_result.get("data") or not url_result["data"]:
|
2025-08-25 01:23:41 +08:00
|
|
|
|
raise DownloadException(f"无法获取音乐ID {music_id} 的播放链接")
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
song_data = url_result["data"][0]
|
|
|
|
|
|
download_url = song_data.get("url", "")
|
2025-08-25 01:23:41 +08:00
|
|
|
|
if not download_url:
|
|
|
|
|
|
raise DownloadException(f"音乐ID {music_id} 无可用的下载链接")
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
detail_result = self.api.get_song_detail(music_id)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
if not detail_result.get("songs") or not detail_result["songs"]:
|
2025-08-25 01:23:41 +08:00
|
|
|
|
raise DownloadException(f"无法获取音乐ID {music_id} 的详细信息")
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
song_detail = detail_result["songs"][0]
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
lyric_result = self.api.get_lyric(music_id, cookies)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
lyric = lyric_result.get("lrc", {}).get("lyric", "") if lyric_result else ""
|
|
|
|
|
|
tlyric = lyric_result.get("tlyric", {}).get("lyric", "") if lyric_result else ""
|
|
|
|
|
|
|
|
|
|
|
|
artists = "/".join(a["name"] for a in song_detail.get("ar", []))
|
|
|
|
|
|
|
|
|
|
|
|
return MusicInfo(
|
2025-08-25 01:23:41 +08:00
|
|
|
|
id=music_id,
|
2026-06-21 04:03:28 +08:00
|
|
|
|
name=song_detail.get("name", "未知歌曲"),
|
|
|
|
|
|
artists=artists or "未知艺术家",
|
|
|
|
|
|
album=song_detail.get("al", {}).get("name", "未知专辑"),
|
|
|
|
|
|
pic_url=song_detail.get("al", {}).get("picUrl", ""),
|
|
|
|
|
|
duration=song_detail.get("dt", 0) // 1000,
|
|
|
|
|
|
track_number=song_detail.get("no", 0),
|
2025-08-25 01:23:41 +08:00
|
|
|
|
download_url=download_url,
|
2026-06-21 04:03:28 +08:00
|
|
|
|
file_type=song_data.get("type", "mp3").lower(),
|
|
|
|
|
|
file_size=song_data.get("size", 0),
|
2025-08-25 01:23:41 +08:00
|
|
|
|
quality=quality,
|
|
|
|
|
|
lyric=lyric,
|
2026-06-21 04:03:28 +08:00
|
|
|
|
tlyric=tlyric,
|
2025-08-25 01:23:41 +08:00
|
|
|
|
)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
except APIException as e:
|
|
|
|
|
|
raise DownloadException(f"API调用失败: {e}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise DownloadException(f"获取音乐信息时发生错误: {e}")
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
# ----------- 文件路径 -----------
|
|
|
|
|
|
|
|
|
|
|
|
def _build_file_path(self, music_info: MusicInfo) -> Path:
|
|
|
|
|
|
"""根据 MusicInfo 生成下载文件路径"""
|
|
|
|
|
|
filename = f"{music_info.artists} - {music_info.name}"
|
|
|
|
|
|
safe = self._sanitize_filename(filename)
|
|
|
|
|
|
ext = self._determine_file_extension(
|
|
|
|
|
|
music_info.download_url,
|
|
|
|
|
|
api_type=music_info.file_type,
|
|
|
|
|
|
)
|
|
|
|
|
|
return self.download_dir / f"{safe}{ext}"
|
|
|
|
|
|
|
|
|
|
|
|
# ----------- 下载(同步) -----------
|
|
|
|
|
|
|
|
|
|
|
|
def download_music_file(
|
|
|
|
|
|
self,
|
|
|
|
|
|
music_id: int,
|
|
|
|
|
|
quality: str = "standard",
|
|
|
|
|
|
) -> DownloadResult:
|
|
|
|
|
|
"""同步下载音乐到本地"""
|
2025-08-25 01:23:41 +08:00
|
|
|
|
try:
|
|
|
|
|
|
music_info = self.get_music_info(music_id, quality)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
file_path = self._build_file_path(music_info)
|
|
|
|
|
|
|
|
|
|
|
|
# 已存在则直接返回
|
2025-08-25 01:23:41 +08:00
|
|
|
|
if file_path.exists():
|
|
|
|
|
|
return DownloadResult(
|
|
|
|
|
|
success=True,
|
|
|
|
|
|
file_path=str(file_path),
|
|
|
|
|
|
file_size=file_path.stat().st_size,
|
2026-06-21 04:03:28 +08:00
|
|
|
|
music_info=music_info,
|
2025-08-25 01:23:41 +08:00
|
|
|
|
)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
# 流式下载
|
2025-08-25 01:23:41 +08:00
|
|
|
|
response = requests.get(music_info.download_url, stream=True, timeout=30)
|
|
|
|
|
|
response.raise_for_status()
|
2026-06-21 04:03:28 +08:00
|
|
|
|
with open(file_path, "wb") as f:
|
2025-08-25 01:23:41 +08:00
|
|
|
|
for chunk in response.iter_content(chunk_size=8192):
|
|
|
|
|
|
if chunk:
|
|
|
|
|
|
f.write(chunk)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
# 写入元数据(失败不影响主流程)
|
|
|
|
|
|
self._write_metadata_with_ffmpeg(file_path, music_info)
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
return DownloadResult(
|
|
|
|
|
|
success=True,
|
|
|
|
|
|
file_path=str(file_path),
|
|
|
|
|
|
file_size=file_path.stat().st_size,
|
2026-06-21 04:03:28 +08:00
|
|
|
|
music_info=music_info,
|
2025-08-25 01:23:41 +08:00
|
|
|
|
)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
except DownloadException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except requests.RequestException as e:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
return DownloadResult(success=False, error_message=f"下载请求失败: {e}")
|
2025-08-25 01:23:41 +08:00
|
|
|
|
except Exception as e:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
return DownloadResult(success=False, error_message=f"下载过程中发生错误: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# ----------- 下载(异步) -----------
|
|
|
|
|
|
|
|
|
|
|
|
async def download_music_file_async(
|
|
|
|
|
|
self,
|
|
|
|
|
|
music_id: int,
|
|
|
|
|
|
quality: str = "standard",
|
|
|
|
|
|
) -> DownloadResult:
|
|
|
|
|
|
"""异步下载音乐到本地"""
|
2025-08-25 01:23:41 +08:00
|
|
|
|
try:
|
|
|
|
|
|
music_info = self.get_music_info(music_id, quality)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
file_path = self._build_file_path(music_info)
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
if file_path.exists():
|
|
|
|
|
|
return DownloadResult(
|
|
|
|
|
|
success=True,
|
|
|
|
|
|
file_path=str(file_path),
|
|
|
|
|
|
file_size=file_path.stat().st_size,
|
2026-06-21 04:03:28 +08:00
|
|
|
|
music_info=music_info,
|
2025-08-25 01:23:41 +08:00
|
|
|
|
)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
async with aiohttp.ClientSession() as session:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
async with session.get(music_info.download_url) as resp:
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
async with aiofiles.open(file_path, "wb") as f:
|
|
|
|
|
|
async for chunk in resp.content.iter_chunked(8192):
|
2025-08-25 01:23:41 +08:00
|
|
|
|
await f.write(chunk)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
self._write_metadata_with_ffmpeg(file_path, music_info)
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
return DownloadResult(
|
|
|
|
|
|
success=True,
|
|
|
|
|
|
file_path=str(file_path),
|
|
|
|
|
|
file_size=file_path.stat().st_size,
|
2026-06-21 04:03:28 +08:00
|
|
|
|
music_info=music_info,
|
2025-08-25 01:23:41 +08:00
|
|
|
|
)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
except DownloadException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except aiohttp.ClientError as e:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
return DownloadResult(success=False, error_message=f"异步下载请求失败: {e}")
|
2025-08-25 01:23:41 +08:00
|
|
|
|
except Exception as e:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
return DownloadResult(success=False, error_message=f"异步下载过程中发生错误: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
# ----------- 下载到内存 -----------
|
|
|
|
|
|
|
|
|
|
|
|
def download_music_to_memory(
|
|
|
|
|
|
self,
|
|
|
|
|
|
music_id: int,
|
|
|
|
|
|
quality: str = "standard",
|
|
|
|
|
|
) -> Tuple[bool, BytesIO, MusicInfo]:
|
|
|
|
|
|
"""下载音乐到内存"""
|
2025-08-25 01:23:41 +08:00
|
|
|
|
try:
|
|
|
|
|
|
music_info = self.get_music_info(music_id, quality)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
resp = requests.get(music_info.download_url, timeout=30)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return True, BytesIO(resp.content), music_info
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
except DownloadException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except requests.RequestException as e:
|
|
|
|
|
|
raise DownloadException(f"下载到内存失败: {e}")
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
raise DownloadException(f"内存下载过程中发生错误: {e}")
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
# ----------- 批量下载 -----------
|
|
|
|
|
|
|
|
|
|
|
|
async def download_batch_async(
|
|
|
|
|
|
self,
|
|
|
|
|
|
music_ids: List[int],
|
|
|
|
|
|
quality: str = "standard",
|
|
|
|
|
|
) -> List[DownloadResult]:
|
|
|
|
|
|
"""批量并发下载"""
|
2025-08-25 01:23:41 +08:00
|
|
|
|
semaphore = asyncio.Semaphore(self.max_concurrent)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
async def _one(music_id: int) -> DownloadResult:
|
2025-08-25 01:23:41 +08:00
|
|
|
|
async with semaphore:
|
|
|
|
|
|
return await self.download_music_file_async(music_id, quality)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
tasks = [_one(mid) for mid in music_ids]
|
2025-08-25 01:23:41 +08:00
|
|
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
processed: List[DownloadResult] = []
|
|
|
|
|
|
for i, r in enumerate(results):
|
|
|
|
|
|
if isinstance(r, Exception):
|
|
|
|
|
|
processed.append(DownloadResult(
|
2025-08-25 01:23:41 +08:00
|
|
|
|
success=False,
|
2026-06-21 04:03:28 +08:00
|
|
|
|
error_message=f"下载音乐ID {music_ids[i]} 时发生异常: {r}",
|
2025-08-25 01:23:41 +08:00
|
|
|
|
))
|
|
|
|
|
|
else:
|
2026-06-21 04:03:28 +08:00
|
|
|
|
processed.append(r)
|
|
|
|
|
|
return processed
|
|
|
|
|
|
|
|
|
|
|
|
# ----------- 进度查询 -----------
|
|
|
|
|
|
|
|
|
|
|
|
def get_download_progress(
|
|
|
|
|
|
self,
|
|
|
|
|
|
music_id: int,
|
|
|
|
|
|
quality: str = "standard",
|
|
|
|
|
|
) -> Dict[str, Any]:
|
|
|
|
|
|
"""查询下载进度"""
|
2025-08-25 01:23:41 +08:00
|
|
|
|
try:
|
|
|
|
|
|
music_info = self.get_music_info(music_id, quality)
|
2026-06-21 04:03:28 +08:00
|
|
|
|
file_path = self._build_file_path(music_info)
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
if file_path.exists():
|
|
|
|
|
|
current_size = file_path.stat().st_size
|
2026-06-21 04:03:28 +08:00
|
|
|
|
progress = (
|
|
|
|
|
|
current_size / music_info.file_size * 100
|
|
|
|
|
|
if music_info.file_size > 0
|
|
|
|
|
|
else 0
|
|
|
|
|
|
)
|
2025-08-25 01:23:41 +08:00
|
|
|
|
return {
|
2026-06-21 04:03:28 +08:00
|
|
|
|
"music_id": music_id,
|
|
|
|
|
|
"filename": file_path.name,
|
|
|
|
|
|
"total_size": music_info.file_size,
|
|
|
|
|
|
"current_size": current_size,
|
|
|
|
|
|
"progress": min(progress, 100),
|
|
|
|
|
|
"completed": current_size >= music_info.file_size,
|
2025-08-25 01:23:41 +08:00
|
|
|
|
}
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
return {
|
2026-06-21 04:03:28 +08:00
|
|
|
|
"music_id": music_id,
|
|
|
|
|
|
"filename": file_path.name,
|
|
|
|
|
|
"total_size": music_info.file_size,
|
|
|
|
|
|
"current_size": 0,
|
|
|
|
|
|
"progress": 0,
|
|
|
|
|
|
"completed": False,
|
2025-08-25 01:23:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-21 04:03:28 +08:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"music_id": music_id,
|
|
|
|
|
|
"error": str(e),
|
|
|
|
|
|
"progress": 0,
|
|
|
|
|
|
"completed": False,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# ============ ffmpeg 元数据写入(核心) ============
|
|
|
|
|
|
|
|
|
|
|
|
def _write_metadata_with_ffmpeg(
|
|
|
|
|
|
self,
|
|
|
|
|
|
file_path: Path,
|
|
|
|
|
|
music_info: MusicInfo,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""使用 ffmpeg 写入音频元数据
|
|
|
|
|
|
|
|
|
|
|
|
处理: title/artist/album/track + 封面(如果是 mp3/flac/m4a/mp4)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否写入成功(失败不影响主流程)
|
|
|
|
|
|
"""
|
|
|
|
|
|
ffmpeg_path = self._resolve_ffmpeg()
|
|
|
|
|
|
if not ffmpeg_path:
|
|
|
|
|
|
print("跳过元数据写入: ffmpeg 不可用")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
file_ext = file_path.suffix.lower()
|
|
|
|
|
|
if file_ext not in METADATA_FORMATS:
|
|
|
|
|
|
print(f"跳过元数据写入: 不支持的格式 {file_ext}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
if not file_path.exists():
|
|
|
|
|
|
print(f"跳过元数据写入: 文件不存在 {file_path}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 临时输出文件:保留原扩展名在最后,便于 ffmpeg 识别 muxer
|
|
|
|
|
|
# 例: 艾辰 - 错位时空.tagging.flac
|
|
|
|
|
|
tmp_output = file_path.with_name(
|
|
|
|
|
|
file_path.stem + ".tagging" + file_path.suffix
|
|
|
|
|
|
)
|
|
|
|
|
|
# 临时封面文件
|
|
|
|
|
|
cover_tmp: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 下载封面到临时文件
|
|
|
|
|
|
if music_info.pic_url:
|
|
|
|
|
|
try:
|
|
|
|
|
|
pic_resp = requests.get(music_info.pic_url, timeout=10)
|
|
|
|
|
|
pic_resp.raise_for_status()
|
|
|
|
|
|
fd, cover_tmp = tempfile.mkstemp(suffix=".jpg", dir=str(self.download_dir))
|
|
|
|
|
|
os.close(fd)
|
|
|
|
|
|
with open(cover_tmp, "wb") as cf:
|
|
|
|
|
|
cf.write(pic_resp.content)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"封面下载失败,跳过封面写入: {e}")
|
|
|
|
|
|
cover_tmp = None
|
|
|
|
|
|
|
|
|
|
|
|
# 构造 ffmpeg 命令
|
|
|
|
|
|
# 顺序: global opts -> -i input1 -> -i input2 -> metadata -> mapping -> -f format -> output
|
|
|
|
|
|
output_format = EXT_TO_FFMPEG_FORMAT.get(file_ext, "")
|
|
|
|
|
|
cmd: List[str] = [
|
|
|
|
|
|
ffmpeg_path, "-y", "-loglevel", "error",
|
|
|
|
|
|
"-i", str(file_path),
|
|
|
|
|
|
]
|
|
|
|
|
|
if cover_tmp:
|
|
|
|
|
|
cmd += ["-i", cover_tmp]
|
|
|
|
|
|
|
|
|
|
|
|
# 通用元数据
|
|
|
|
|
|
meta = {
|
|
|
|
|
|
"title": music_info.name,
|
|
|
|
|
|
"artist": music_info.artists,
|
|
|
|
|
|
"album": music_info.album,
|
|
|
|
|
|
"album_artist": music_info.artists,
|
|
|
|
|
|
"comment": "Downloaded from Netease Cloud Music",
|
|
|
|
|
|
}
|
|
|
|
|
|
if music_info.track_number > 0:
|
|
|
|
|
|
meta["track"] = str(music_info.track_number)
|
|
|
|
|
|
meta["tracktotal"] = str(music_info.track_number)
|
|
|
|
|
|
for k, v in meta.items():
|
|
|
|
|
|
if v:
|
|
|
|
|
|
cmd += ["-metadata", f"{k}={v}"]
|
|
|
|
|
|
|
|
|
|
|
|
# 各格式的 mapping / 编码选项
|
|
|
|
|
|
cmd += self._build_format_args(file_ext, has_cover=bool(cover_tmp))
|
|
|
|
|
|
|
|
|
|
|
|
# -f 必须放在所有 -i 之后(作为 output option)才生效
|
|
|
|
|
|
if output_format:
|
|
|
|
|
|
cmd += ["-f", output_format]
|
|
|
|
|
|
cmd.append(str(tmp_output))
|
|
|
|
|
|
|
|
|
|
|
|
# 执行
|
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
|
cmd,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=120,
|
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
|
errors="ignore",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if result.returncode == 0 and tmp_output.exists():
|
|
|
|
|
|
# 原子替换原文件
|
|
|
|
|
|
shutil.move(str(tmp_output), str(file_path))
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"ffmpeg 写入元数据失败: "
|
|
|
|
|
|
f"{(result.stderr or result.stdout or '').strip()}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
|
|
print("ffmpeg 执行超时")
|
|
|
|
|
|
return False
|
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
|
print(f"ffmpeg 可执行文件未找到: {ffmpeg_path}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"ffmpeg 写入元数据异常: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
finally:
|
|
|
|
|
|
# 清理封面临时文件
|
|
|
|
|
|
if cover_tmp and os.path.exists(cover_tmp):
|
|
|
|
|
|
try:
|
|
|
|
|
|
os.unlink(cover_tmp)
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
# 清理可能残留的临时输出
|
|
|
|
|
|
if tmp_output.exists():
|
|
|
|
|
|
try:
|
|
|
|
|
|
tmp_output.unlink()
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
def _build_format_args(self, file_ext: str, has_cover: bool) -> List[str]:
|
|
|
|
|
|
"""根据文件格式构造 mapping / 编码参数"""
|
|
|
|
|
|
if file_ext == ".mp3":
|
|
|
|
|
|
# MP3: 封面通过 ID3v2 APIC 帧写入
|
|
|
|
|
|
if has_cover:
|
|
|
|
|
|
return [
|
|
|
|
|
|
"-map", "0:a", "-map", "1:v",
|
|
|
|
|
|
"-c:a", "copy", "-c:v", "copy",
|
|
|
|
|
|
"-id3v2_version", "3",
|
|
|
|
|
|
"-metadata:s:v", "title=Album cover",
|
|
|
|
|
|
"-metadata:s:v", "comment=Cover (front)",
|
|
|
|
|
|
]
|
|
|
|
|
|
return ["-map", "0:a", "-c:a", "copy", "-id3v2_version", "3"]
|
|
|
|
|
|
|
|
|
|
|
|
if file_ext == ".flac":
|
|
|
|
|
|
# FLAC: 原生支持封面
|
|
|
|
|
|
if has_cover:
|
|
|
|
|
|
return [
|
|
|
|
|
|
"-map", "0", "-map", "1",
|
|
|
|
|
|
"-c", "copy",
|
|
|
|
|
|
"-disposition:v:0", "attached_pic",
|
|
|
|
|
|
]
|
|
|
|
|
|
return ["-map", "0", "-c", "copy"]
|
|
|
|
|
|
|
|
|
|
|
|
if file_ext == ".m4a":
|
|
|
|
|
|
# M4A: AAC 音频 + mp4 容器
|
|
|
|
|
|
if has_cover:
|
|
|
|
|
|
return [
|
|
|
|
|
|
"-map", "0:a", "-map", "1:v",
|
|
|
|
|
|
"-c", "copy",
|
|
|
|
|
|
"-disposition:v:0", "attached_pic",
|
|
|
|
|
|
"-metadata:s:v", "title=Album cover",
|
|
|
|
|
|
"-metadata:s:v", "comment=Cover (front)",
|
|
|
|
|
|
]
|
|
|
|
|
|
return ["-map", "0:a", "-c:a", "copy"]
|
|
|
|
|
|
|
|
|
|
|
|
if file_ext == ".mp4":
|
|
|
|
|
|
# MP4 (杜比全景声 EAC3 / 其它 mp4 容器音频)
|
|
|
|
|
|
# 杜比文件通常无视频流,封面作为 video stream 注入;+faststart 让 moov 在文件头
|
|
|
|
|
|
if has_cover:
|
|
|
|
|
|
return [
|
|
|
|
|
|
"-map", "0", "-map", "1:v",
|
|
|
|
|
|
"-c", "copy",
|
|
|
|
|
|
"-disposition:v:0", "attached_pic",
|
|
|
|
|
|
"-metadata:s:v", "title=Album cover",
|
|
|
|
|
|
"-metadata:s:v", "comment=Cover (front)",
|
|
|
|
|
|
"-movflags", "+faststart",
|
|
|
|
|
|
]
|
|
|
|
|
|
return ["-map", "0", "-c", "copy", "-movflags", "+faststart"]
|
|
|
|
|
|
|
|
|
|
|
|
if file_ext in (".ogg", ".opus"):
|
|
|
|
|
|
if has_cover:
|
|
|
|
|
|
return [
|
|
|
|
|
|
"-map", "0:a", "-map", "1:v",
|
|
|
|
|
|
"-c:a", "copy", "-c:v", "copy",
|
|
|
|
|
|
]
|
|
|
|
|
|
return ["-map", "0:a", "-c:a", "copy"]
|
|
|
|
|
|
|
|
|
|
|
|
# 默认
|
|
|
|
|
|
if has_cover:
|
|
|
|
|
|
return ["-map", "0:a", "-map", "1:v", "-c", "copy"]
|
|
|
|
|
|
return ["-map", "0:a", "-c:a", "copy"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============ CLI 入口 ============
|
2025-08-25 01:23:41 +08:00
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-06-21 04:03:28 +08:00
|
|
|
|
import sys
|
|
|
|
|
|
from dataclasses import asdict
|
|
|
|
|
|
|
2025-08-25 01:23:41 +08:00
|
|
|
|
downloader = MusicDownloader()
|
2026-06-21 04:03:28 +08:00
|
|
|
|
|
|
|
|
|
|
# 无参数:打印模块信息
|
|
|
|
|
|
if len(sys.argv) < 2:
|
|
|
|
|
|
print("网易云音乐下载器")
|
|
|
|
|
|
print(f"ffmpeg 路径: {downloader.ffmpeg_path or '未找到'}")
|
|
|
|
|
|
print("支持的音质: standard / exhigh / lossless / hires / sky / jyeffect / jymaster / dolby")
|
|
|
|
|
|
print("支持的功能: 同步/异步/批量下载、内存下载、进度查询、ffmpeg 元数据写入")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("用法:")
|
|
|
|
|
|
print(" python music_downloader.py <music_id> [quality] # 仅打印信息")
|
|
|
|
|
|
print(" python music_downloader.py <music_id> [quality] --download # 打印并下载")
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("示例:")
|
|
|
|
|
|
print(" python music_downloader.py 123456")
|
|
|
|
|
|
print(" python music_downloader.py 123456 lossless")
|
|
|
|
|
|
print(" python music_downloader.py 123456 hires --download")
|
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
|
|
# 解析参数
|
|
|
|
|
|
try:
|
|
|
|
|
|
music_id = int(sys.argv[1])
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
print(f"错误: music_id 必须是整数,收到: {sys.argv[1]!r}")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
quality = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith("--") else "lossless"
|
|
|
|
|
|
do_download = "--download" in sys.argv
|
|
|
|
|
|
|
|
|
|
|
|
# 打印音乐信息
|
|
|
|
|
|
try:
|
|
|
|
|
|
info = downloader.get_music_info(music_id, quality)
|
|
|
|
|
|
except DownloadException as e:
|
|
|
|
|
|
print(f"获取音乐信息失败: {e}")
|
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("=" * 70)
|
|
|
|
|
|
print(f"音乐信息 (ID={info.id}, quality={info.quality})")
|
|
|
|
|
|
print("=" * 70)
|
|
|
|
|
|
|
|
|
|
|
|
info_dict = asdict(info)
|
|
|
|
|
|
for key, value in info_dict.items():
|
|
|
|
|
|
# 歌词太长,只显示行数
|
|
|
|
|
|
if key in ("lyric", "tlyric"):
|
|
|
|
|
|
lines = [ln for ln in (value or "").splitlines() if ln.strip()]
|
|
|
|
|
|
preview = " | ".join(lines[:2])[:80] if lines else "(空)"
|
|
|
|
|
|
print(f" {key:<14}: {len(lines)} 行 预览: {preview}{'...' if len(lines) > 2 else ''}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
# URL 类字段:完整展示(如果终端窄可换行)
|
|
|
|
|
|
if key in ("download_url", "pic_url"):
|
|
|
|
|
|
print(f" {key:<14}: {value}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
print(f" {key:<14}: {value}")
|
|
|
|
|
|
|
|
|
|
|
|
print("=" * 70)
|
|
|
|
|
|
|
|
|
|
|
|
# 可选:触发下载
|
|
|
|
|
|
if do_download:
|
|
|
|
|
|
print()
|
|
|
|
|
|
print("开始下载...")
|
|
|
|
|
|
result = downloader.download_music_file(music_id, quality)
|
|
|
|
|
|
if result.success:
|
|
|
|
|
|
print(f"下载完成: {result.file_path} ({result.file_size} 字节)")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f"下载失败: {result.error_message}")
|
|
|
|
|
|
sys.exit(1)
|