Archived
在框架迁移与模块拆分前保存当前可运行状态: - 后端:main.py/auth_routes.py/auth_system.py/music_api.py/music_downloader.py/podcast_api.py/qr_login.py - 前端:templates/index.html + static/site_auth.js - 部署:Dockerfile/docker-compose.yml/entrypoint.sh/requirements.txt 敏感数据(data/、cookie.txt)与内部文档(交接文档/新会话引导)按 .gitignore 排除。
1174 lines
51 KiB
Python
1174 lines
51 KiB
Python
"""网易云音乐下载器(适配 ffmpeg 写入元数据)
|
||
|
||
提供功能:
|
||
- 同步 / 异步下载(本地落盘 / 内存返回)
|
||
- 批量并发下载
|
||
- 进度查询
|
||
- 基于 ffmpeg 的元数据 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus)
|
||
|
||
设计要点:
|
||
1. ffmpeg 路径懒加载:每次写入前重新检测,支持服务启动后中途安装
|
||
2. 显式 -f 指定输出格式 + 临时文件保留原扩展名在最后,双保险
|
||
3. MP4(杜比全景声 EAC3)单独分支,启用 +faststart
|
||
4. 扩展名优先使用 API 返回的 type 字段,URL/Content-Type 兜底
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import contextlib
|
||
import logging
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
from collections import deque
|
||
from dataclasses import dataclass
|
||
from enum import Enum
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
import requests
|
||
from requests.adapters import HTTPAdapter
|
||
|
||
from music_api import APIException, NeteaseAPI, load_cookies
|
||
|
||
# P2-5:worker 线程的输出一律走 logger(坑 11——NSSM GBK 重定向下裸 print
|
||
# 非可编码字符会 UnicodeEncodeError 崩掉 worker,尤其 ffmpeg stderr 原文回显)
|
||
logger = logging.getLogger('music_downloader')
|
||
|
||
|
||
# ============ HTTP 连接复用(提速核心) ============
|
||
|
||
# 常见浏览器 UA,避免直链 CDN 因空 UA 限速/拒绝
|
||
DEFAULT_UA = (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||
)
|
||
|
||
# 音频流式下载分块大小(256KB):减少系统调用与磁盘 IO 次数,提升吞吐
|
||
DL_CHUNK_SIZE = 256 * 1024
|
||
|
||
# 线程本地 Session:同线程内复用 TCP/TLS 连接(keep-alive),批量并发显著提速
|
||
_SESSION_LOCAL = threading.local()
|
||
|
||
|
||
# 第 97 条(P3):下载内容嗅探的音频魔数(未知期望尺寸时唯一的内容防线)
|
||
_AUDIO_MAGIC = (b'ID3', b'fLaC', b'OggS', b'RIFF')
|
||
|
||
|
||
def _sniff_audio_header(path) -> bool:
|
||
"""读取文件头做基础音频魔数校验:MP3(ID3/裸帧)、FLAC、OGG/Opus、WAV(RIFF)、
|
||
M4A/MP4(ftyp)。未知期望尺寸的下载走"非空即通过"保留口径,本函数补上内容
|
||
防线——CDN 错误页/风控 JSON 不再能被存成音频交付。"""
|
||
try:
|
||
with open(path, 'rb') as f:
|
||
head = f.read(16)
|
||
except OSError:
|
||
return False
|
||
if not head:
|
||
return False
|
||
if any(head.startswith(m) for m in _AUDIO_MAGIC):
|
||
return True
|
||
if len(head) >= 8 and head[:4] == b'\x00\x00\x00' and head[4:8] == b'ftyp':
|
||
return True
|
||
return len(head) >= 2 and head[0] == 0xFF and (head[1] & 0xE0) == 0xE0
|
||
|
||
|
||
def get_http_session() -> requests.Session:
|
||
"""获取当前线程的共享 requests.Session(带连接池 + keep-alive)"""
|
||
session: Optional[requests.Session] = getattr(_SESSION_LOCAL, "session", None)
|
||
if session is None:
|
||
session = requests.Session()
|
||
adapter = HTTPAdapter(pool_connections=8, pool_maxsize=32, max_retries=0)
|
||
session.mount("https://", adapter)
|
||
session.mount("http://", adapter)
|
||
session.headers.update({"User-Agent": DEFAULT_UA})
|
||
_SESSION_LOCAL.session = session
|
||
return session
|
||
|
||
|
||
# per-file 写锁:保证同一目标文件的 .part 临时文件同时只有一个写入者,
|
||
# 防止并发下载同名歌曲(不同用户/任务)互相覆盖导致文件损坏
|
||
# F78:锁表带引用计数与回收——无人引用时从表中移除,防止长期运行无界增长
|
||
# A3:计数在注册表守卫锁内完成("取出引用"即计数 +1),消除"取引用后、acquire 前
|
||
# 被另一个线程回收、第三个线程拿到新锁对象"的互斥破坏窗口
|
||
_FILE_LOCKS: Dict[str, "RefCountedFileLock"] = {}
|
||
_FILE_LOCKS_GUARD = threading.Lock()
|
||
|
||
|
||
class RefCountedFileLock:
|
||
"""带引用计数的文件锁:refs 表示"已持有或已登记即将持有"的数量,归零可回收"""
|
||
|
||
def __init__(self):
|
||
self._lock = threading.Lock()
|
||
self.refs = 0
|
||
|
||
def acquire(self) -> None:
|
||
self._lock.acquire()
|
||
|
||
def release(self) -> None:
|
||
self._lock.release()
|
||
|
||
def locked(self) -> bool:
|
||
return self._lock.locked()
|
||
|
||
|
||
class _FileLockHandle:
|
||
"""兼容 `with get_file_lock(path):` 旧用法的句柄:语义与 file_lock_scope 一致"""
|
||
|
||
def __init__(self, path: Path):
|
||
self._path = path
|
||
self._lock = None
|
||
self._key = ''
|
||
|
||
def __enter__(self) -> "RefCountedFileLock":
|
||
key = str(self._path)
|
||
with _FILE_LOCKS_GUARD:
|
||
lock = _FILE_LOCKS.get(key)
|
||
if lock is None:
|
||
lock = RefCountedFileLock()
|
||
_FILE_LOCKS[key] = lock
|
||
lock.refs += 1
|
||
self._lock = lock
|
||
self._key = key
|
||
try:
|
||
lock.acquire()
|
||
except BaseException:
|
||
# R5-17:acquire 被打断时回滚引用计数并摘除空表项,防锁表条目永久泄漏
|
||
with _FILE_LOCKS_GUARD:
|
||
lock.refs -= 1
|
||
if lock.refs <= 0 and not lock.locked():
|
||
_FILE_LOCKS.pop(key, None)
|
||
self._lock = None
|
||
self._key = ''
|
||
raise
|
||
return lock
|
||
|
||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||
if self._lock is None:
|
||
return
|
||
self._lock.release()
|
||
with _FILE_LOCKS_GUARD:
|
||
self._lock.refs -= 1
|
||
if self._lock.refs <= 0 and not self._lock.locked():
|
||
_FILE_LOCKS.pop(self._key, None)
|
||
self._lock = None
|
||
|
||
|
||
def get_file_lock(path: Path) -> "_FileLockHandle":
|
||
"""兼容入口:返回可用 with 语句的锁句柄(A3:引用计数在守卫锁内登记,无回收竞态)"""
|
||
return _FileLockHandle(path)
|
||
|
||
|
||
# 封面字节缓存:同一专辑封面只从网络拉取一次(ffmpeg 写元数据 与 ZIP 打包 共用)
|
||
_COVER_CACHE: Dict[str, bytes] = {}
|
||
_COVER_CACHE_ORDER: deque = deque(maxlen=128)
|
||
_COVER_CACHE_LOCK = threading.Lock()
|
||
|
||
|
||
def fetch_cover_bytes(pic_url: str) -> Optional[bytes]:
|
||
"""下载并缓存封面图片字节;失败返回 None(B3:字节预算 10MB,拒绝超大盘点阵/伪装文件)"""
|
||
if not pic_url:
|
||
return None
|
||
with _COVER_CACHE_LOCK:
|
||
cached = _COVER_CACHE.get(pic_url)
|
||
if cached:
|
||
return cached
|
||
try:
|
||
resp = get_http_session().get(pic_url, timeout=15, stream=True)
|
||
try:
|
||
resp.raise_for_status()
|
||
try:
|
||
declared = int(resp.headers.get('Content-Length') or 0)
|
||
except ValueError:
|
||
declared = 0 # 第 91 条(P3):垃圾 Content-Length 不炸,按未知长度走流式
|
||
if declared > 10 * 1024 * 1024:
|
||
return None
|
||
# bytearray 就地追加(原 bytes += 是 O(n²) 拷贝,10MB 封面最坏多拷上百 MB)
|
||
data = bytearray()
|
||
for chunk in resp.iter_content(chunk_size=64 * 1024):
|
||
data += chunk
|
||
if len(data) > 10 * 1024 * 1024:
|
||
return None
|
||
if not data:
|
||
return None
|
||
data = bytes(data)
|
||
finally:
|
||
# 第 91 条(P3):所有路径统一关闭响应(原 Content-Length 异常路径漏关)
|
||
resp.close()
|
||
except Exception:
|
||
return None
|
||
with _COVER_CACHE_LOCK:
|
||
if pic_url not in _COVER_CACHE:
|
||
# 简单 FIFO 淘汰,防止缓存无界增长
|
||
while len(_COVER_CACHE_ORDER) >= _COVER_CACHE_ORDER.maxlen:
|
||
evict = _COVER_CACHE_ORDER.popleft()
|
||
_COVER_CACHE.pop(evict, None)
|
||
_COVER_CACHE[pic_url] = data
|
||
_COVER_CACHE_ORDER.append(pic_url)
|
||
return data
|
||
|
||
|
||
# ============ 类型定义 ============
|
||
|
||
class QualityLevel(Enum):
|
||
"""音质等级枚举"""
|
||
STANDARD = "standard"
|
||
EXHIGH = "exhigh"
|
||
LOSSLESS = "lossless"
|
||
HIRES = "hires"
|
||
SKY = "sky"
|
||
JYEFFECT = "jyeffect"
|
||
JYMASTER = "jymaster"
|
||
DOLBY = "dolby"
|
||
|
||
|
||
@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 = ""
|
||
publish_year: Optional[int] = None
|
||
|
||
|
||
@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
|
||
|
||
|
||
class DownloadCanceled(DownloadException):
|
||
"""下载被用户取消"""
|
||
pass
|
||
|
||
|
||
class _RetryFromScratch(requests.RequestException):
|
||
"""内部信号:断点续传起点失效(HTTP 416 等),清除 .part 后从头重试"""
|
||
pass
|
||
|
||
|
||
class CountingSemaphore:
|
||
"""带活跃/等待计数的信号量(线程安全),用于实时展示排队情况
|
||
|
||
用法与 threading.BoundedSemaphore 一致(支持 with 语句),
|
||
额外维护 active(正在执行)/ waiting(排队等待)两个计数,
|
||
供前端实时展示排队人数与预计等待时间。
|
||
"""
|
||
|
||
def __init__(self, value: int = 1):
|
||
self.capacity = max(1, int(value))
|
||
self._semaphore = threading.BoundedSemaphore(self.capacity)
|
||
self._lock = threading.Lock()
|
||
self.active = 0
|
||
self.waiting = 0
|
||
|
||
def acquire(self, blocking: bool = True) -> bool:
|
||
if not blocking:
|
||
if not self._semaphore.acquire(blocking=False):
|
||
return False
|
||
with self._lock:
|
||
self.active += 1
|
||
return True
|
||
with self._lock:
|
||
self.waiting += 1
|
||
try:
|
||
self._semaphore.acquire()
|
||
except BaseException:
|
||
with self._lock:
|
||
self.waiting = max(0, self.waiting - 1)
|
||
raise
|
||
with self._lock:
|
||
self.waiting = max(0, self.waiting - 1)
|
||
self.active += 1
|
||
return True
|
||
|
||
def acquire_cancellable(self, cancel_ev, poll: float = 1.0) -> bool:
|
||
"""可取消地等待名额(F91):等待期间 cancel_ev 置位立即返回 False,
|
||
不再无期限阻塞线程与退款。返回 True 表示已获得名额。"""
|
||
import time as _time
|
||
with self._lock:
|
||
self.waiting += 1
|
||
try:
|
||
while True:
|
||
if cancel_ev is not None and cancel_ev.is_set():
|
||
return False
|
||
if self._semaphore.acquire(blocking=False):
|
||
with self._lock:
|
||
self.active += 1
|
||
return True
|
||
_time.sleep(poll)
|
||
finally:
|
||
with self._lock:
|
||
self.waiting = max(0, self.waiting - 1)
|
||
|
||
def release(self) -> None:
|
||
self._semaphore.release()
|
||
with self._lock:
|
||
self.active = max(0, self.active - 1)
|
||
|
||
def __enter__(self) -> "CountingSemaphore":
|
||
self.acquire()
|
||
return self
|
||
|
||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||
self.release()
|
||
|
||
|
||
# ============ 工具映射表 ============
|
||
|
||
# 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"}
|
||
|
||
|
||
# ============ 核心实现 ============
|
||
|
||
class MusicDownloader:
|
||
"""网易云音乐下载器
|
||
|
||
用法:
|
||
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,
|
||
global_max_concurrent: int = 10,
|
||
):
|
||
"""
|
||
Args:
|
||
download_dir: 下载目录
|
||
max_concurrent: 单个批量任务内的最大并发下载数
|
||
ffmpeg_path: ffmpeg 可执行文件路径,为 None 时启动时检测一次
|
||
global_max_concurrent: 整个服务同时下载的文件数(含 ffmpeg 元数据写入)。
|
||
所有请求共享该信号量,防止大量用户同时下载导致内存/CPU 耗尽。
|
||
"""
|
||
self.download_dir = Path(download_dir)
|
||
self.download_dir.mkdir(exist_ok=True)
|
||
self.max_concurrent = max_concurrent
|
||
|
||
self.api = NeteaseAPI()
|
||
|
||
# 全局并发信号量:所有请求共用,限制同时进行的下载数
|
||
# (带计数,供前端实时展示排队情况)
|
||
self._download_semaphore = CountingSemaphore(global_max_concurrent)
|
||
|
||
# ffmpeg 元数据写入专用信号量(CPU 密集操作):
|
||
# 与下载分离限流 —— 元数据写满时只阻塞元数据,不占下载名额、不拖慢其它文件的传输
|
||
cpu_count = os.cpu_count() or 2
|
||
self._meta_semaphore = threading.BoundedSemaphore(max(1, min(4, cpu_count)))
|
||
|
||
# ffmpeg 路径缓存:启动时检测一次,写入时还会再懒加载一次
|
||
self.ffmpeg_path = ffmpeg_path or shutil.which("ffmpeg")
|
||
if not self.ffmpeg_path:
|
||
logger.warning("未检测到 ffmpeg,写入元数据功能将不可用")
|
||
|
||
# ----------- 工具方法 -----------
|
||
|
||
def _sanitize_filename(self, filename: str, max_bytes: int = 180) -> str:
|
||
"""清理文件名非法字符。
|
||
F79:换行/回车/制表等控制字符一律剔除(否则 send_file 头部 500);
|
||
F32:按 UTF-8 字节截断(Linux 文件系统 255 字节上限,中文按字符截 200 会超),
|
||
且从 _build_file_path 传入时保证尾部 [ID 音质] 标记不被截掉。"""
|
||
filename = re.sub(r'[\x00-\x1f\x7f]', '', filename)
|
||
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
|
||
filename = filename.strip(' .')
|
||
# 按字节截断(在字符边界上回退,不产生半个字符)
|
||
raw = filename.encode('utf-8')
|
||
if len(raw) > max_bytes:
|
||
cut = filename.encode('utf-8')[:max_bytes]
|
||
# 回退到合法 UTF-8 边界
|
||
while cut and (cut[-1] & 0xC0) == 0x80:
|
||
cut = cut[:-1]
|
||
filename = cut.decode('utf-8', errors='ignore').rstrip(' .')
|
||
return filename or "unknown"
|
||
|
||
def _determine_file_extension(
|
||
self,
|
||
url: str,
|
||
content_type: str = "",
|
||
api_type: str = "",
|
||
) -> str:
|
||
"""确定文件扩展名
|
||
|
||
优先级: API 返回的 type > URL 后缀 > HTTP Content-Type > 默认 mp3
|
||
"""
|
||
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
|
||
|
||
@staticmethod
|
||
def _unlink_quiet(path: Path) -> None:
|
||
"""安静地删除文件,忽略不存在 / 权限错误"""
|
||
try:
|
||
path.unlink()
|
||
except OSError:
|
||
pass
|
||
|
||
# ----------- 信息获取 -----------
|
||
|
||
def get_music_info(self, music_id: int, quality: str = "standard",
|
||
cookies: Optional[Dict[str, str]] = None) -> MusicInfo:
|
||
"""获取音乐详细信息(同步,URL/详情/歌词 3 个接口并行请求,加快单曲/批量启动)"""
|
||
try:
|
||
cookies = cookies if cookies is not None else load_cookies()
|
||
|
||
# 3 个接口并行请求,单曲下载/批量预取都能明显提速
|
||
# F99:URL/详情拿到后立即 shutdown(cancel_futures),
|
||
# 已知失败不再被无关的歌词请求拖着等完才报错
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
_pool = ThreadPoolExecutor(max_workers=3)
|
||
url_f = _pool.submit(self.api.get_song_url, music_id, quality, cookies)
|
||
detail_f = _pool.submit(self.api.get_song_detail, music_id)
|
||
lyric_f = _pool.submit(self.api.get_lyric, music_id, cookies)
|
||
try:
|
||
url_result = url_f.result()
|
||
detail_result = detail_f.result()
|
||
try:
|
||
lyric_result = lyric_f.result()
|
||
except Exception:
|
||
lyric_result = None # 歌词接口抖动不阻断解析/下载
|
||
finally:
|
||
try:
|
||
_pool.shutdown(wait=False, cancel_futures=True)
|
||
except TypeError:
|
||
_pool.shutdown(wait=False)
|
||
|
||
if not url_result.get("data") or not url_result["data"]:
|
||
raise DownloadException(f"无法获取音乐ID {music_id} 的播放链接")
|
||
|
||
# 第 91 条(P2-8):上游 data 数组元素可能为 null(F94 同族)
|
||
song_data = url_result["data"][0] or {}
|
||
download_url = song_data.get("url", "") or ""
|
||
if not download_url:
|
||
raise DownloadException(f"音乐ID {music_id} 无可用的下载链接")
|
||
|
||
if not detail_result.get("songs") or not detail_result["songs"]:
|
||
raise DownloadException(f"无法获取音乐ID {music_id} 的详细信息")
|
||
|
||
song_detail = detail_result["songs"][0]
|
||
|
||
# F94:上游可选字段可能为 null(而非缺失),一律 (x or {}) / (x or []) 容错,
|
||
# 可选展示信息异常不再让整首有效歌曲解析失败
|
||
lyric = ((lyric_result or {}).get("lrc") or {}).get("lyric", "") or ""
|
||
tlyric = ((lyric_result or {}).get("tlyric") or {}).get("lyric", "") or ""
|
||
|
||
artists = "/".join(filter(None, ((a or {}).get("name") for a in (song_detail.get("ar") or []))))
|
||
|
||
al = song_detail.get("al") or {}
|
||
publish_time = al.get("publishTime", 0) or 0
|
||
publish_year = None
|
||
if publish_time:
|
||
import datetime
|
||
try:
|
||
publish_year = datetime.datetime.fromtimestamp(publish_time / 1000).year
|
||
except (OSError, ValueError, OverflowError):
|
||
publish_year = None
|
||
|
||
return MusicInfo(
|
||
id=music_id,
|
||
name=song_detail.get("name") or "未知歌曲",
|
||
artists=artists or "未知艺术家",
|
||
album=al.get("name") or "未知专辑",
|
||
pic_url=al.get("picUrl") or "",
|
||
duration=(song_detail.get("dt") or 0) // 1000,
|
||
track_number=song_detail.get("no", 0) or 0,
|
||
download_url=download_url,
|
||
file_type=(song_data.get("type") or "mp3").lower(), # 第 91 条(P2-8):null 容错
|
||
file_size=song_data.get("size", 0) or 0,
|
||
quality=quality,
|
||
lyric=lyric,
|
||
tlyric=tlyric,
|
||
publish_year=publish_year,
|
||
)
|
||
|
||
except APIException as e:
|
||
raise DownloadException(f"API调用失败: {e}")
|
||
except Exception as e:
|
||
raise DownloadException(f"获取音乐信息时发生错误: {e}")
|
||
|
||
# ----------- 文件路径 -----------
|
||
|
||
def _build_file_path(self, music_info: MusicInfo) -> Path:
|
||
"""根据 MusicInfo 生成下载文件路径
|
||
|
||
文件名附带歌曲 ID 与请求音质:不同歌曲同名(不同版本/重复收录)落盘互不冲突;
|
||
同一首歌不同音质也各自独立,避免低音质文件被当作高音质交付(调包)。
|
||
交付文件名由调用方另行决定,不受影响。
|
||
F32:先截断主名(按字节),再拼接 [ID 音质] 标记——标记永不丢失,
|
||
杜绝截断后两首不同 ID 的歌落到同一路径。
|
||
"""
|
||
base = f"{music_info.artists} - {music_info.name}"
|
||
tag = f" [{music_info.id} {music_info.quality}]"
|
||
ext = self._determine_file_extension(
|
||
music_info.download_url,
|
||
api_type=music_info.file_type,
|
||
)
|
||
# 第 97 条(P3):按完整路径反算文件名可用长度——旧口径只按 Linux 255 字节
|
||
# 截断文件名,未启用 LongPathsEnabled 的 Windows 上超长歌名会在 open 时
|
||
# OSError 白白重试 3 轮。预算 = 240 - 目录前缀 - 扩展名 - ".part"等余量;
|
||
# 中文名 UTF-8 字节数 ≥ 字符数,按字节截断天然保守。
|
||
# 目录过深时按比例压缩 tag 预算(对抗复审补:下限保底仍可能超 260 的
|
||
# 病理场景由部署侧控制 downloads 目录长度,见交接文档第 97 条备案)
|
||
budget = 240 - len(str(self.download_dir)) - len(ext) - 8
|
||
if budget < 124:
|
||
tag_budget = max(24, budget // 3)
|
||
else:
|
||
tag_budget = 60
|
||
safe_base = self._sanitize_filename(base, max_bytes=max(32, budget - tag_budget))
|
||
safe_tag = self._sanitize_filename(tag, max_bytes=tag_budget)
|
||
return self.download_dir / f"{safe_base}{safe_tag}{ext}"
|
||
|
||
# ----------- 下载(同步) -----------
|
||
|
||
def download_music_file(
|
||
self,
|
||
music_id: int,
|
||
quality: str = "standard",
|
||
write_meta: bool = True,
|
||
meta_fields: Optional[List[str]] = None,
|
||
cookies: Optional[Dict[str, str]] = None,
|
||
retries: int = 2,
|
||
cancel_event: Optional[threading.Event] = None,
|
||
music_info: Optional[MusicInfo] = None,
|
||
progress_cb=None,
|
||
) -> DownloadResult:
|
||
"""同步下载音乐到本地
|
||
|
||
Args:
|
||
music_id: 音乐ID
|
||
quality: 音质
|
||
write_meta: 是否写入元数据
|
||
meta_fields: 写入字段列表,None 表示全部
|
||
cookies: 用于获取直链/歌词的 Cookie,None 表示全局账号
|
||
retries: 网络/写入失败时的重试次数(默认 2 次)
|
||
cancel_event: 取消事件,置位后立即中止下载(抛 DownloadCanceled)
|
||
music_info: 已预取的歌曲信息(跳过重复 API 调用,用于批量加速)
|
||
progress_cb: 可选进度回调 progress_cb(written_bytes, total_bytes)。
|
||
每写一个分块调用一次(含续传起点);回调内部的异常会被吞掉,
|
||
绝不影响下载主流程。调用方自行限频。
|
||
|
||
说明:
|
||
- 流式下载到 .part 临时文件,完整下载后再原子改名,半截文件不会被当作已下载
|
||
- 断点续传:失败保留 .part,重试/下次下载从已写字节续传(服务器支持 Range 时)
|
||
- per-file 锁:同名歌曲并发下载互斥,防止 .part 被交叉写入损坏
|
||
- 连接复用:线程级 Session + keep-alive,避免每首歌重建 TCP/TLS 连接
|
||
- 网络传输与 ffmpeg 元数据写入分离限流,互不抢占对方资源
|
||
"""
|
||
if music_info is None:
|
||
music_info = self.get_music_info(music_id, quality, cookies)
|
||
|
||
file_path = self._build_file_path(music_info)
|
||
part_path0 = file_path.with_name(file_path.name + ".part")
|
||
last_error = ""
|
||
|
||
next_wait = 0.0
|
||
for attempt in range(retries + 1):
|
||
# 第 91 条(P3):重试退避睡眠在文件锁外执行——同名并发任务不再陪睡
|
||
if next_wait > 0:
|
||
time.sleep(next_wait)
|
||
next_wait = 0.0
|
||
# F117:重试轮次顶部的取消检查也要清理 .part(原实现在 try 外抛出会绕过清理)
|
||
if cancel_event is not None and cancel_event.is_set():
|
||
self._unlink_quiet(part_path0)
|
||
raise DownloadCanceled("下载已取消")
|
||
|
||
# 同一目标文件的下载者串行化(不同文件之间仍然并行)
|
||
with get_file_lock(file_path):
|
||
try:
|
||
# 其它线程可能刚好完成此文件,锁内复查
|
||
if file_path.exists():
|
||
# 缓存与本次期望不符(跨账号音质降级/上游变更都会让同 ID 同音质
|
||
# 的实际文件不同):视为缓存 miss 删除重下,而不是把旧文件
|
||
# 当本次结果交付(原实现命中即返回、从不校验尺寸)。
|
||
# 第 91 条(P3):期望未知(file_size=0)时至少拒绝 0 字节坏文件
|
||
st_size = file_path.stat().st_size
|
||
cache_bad = ((music_info.file_size and st_size != music_info.file_size)
|
||
or (not music_info.file_size and st_size == 0))
|
||
if cache_bad:
|
||
self._unlink_quiet(file_path)
|
||
else:
|
||
# F31:缓存命中也尊重本次的元数据请求(重跑一次 ffmpeg,幂等)
|
||
if write_meta:
|
||
with self._meta_semaphore:
|
||
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
|
||
return DownloadResult(
|
||
success=True,
|
||
file_path=str(file_path),
|
||
file_size=file_path.stat().st_size,
|
||
music_info=music_info,
|
||
)
|
||
|
||
part_path = file_path.with_name(file_path.name + ".part")
|
||
resume_from = 0
|
||
if part_path.exists():
|
||
existing = part_path.stat().st_size
|
||
if music_info.file_size > 0 and existing == music_info.file_size:
|
||
# 上次已下完但未改名:直接转正(F30:转正后立即返回,
|
||
# 不再发起网络请求——原实现离线时会把现成完整文件报为失败)
|
||
# R5-15:改 >= 为 ==——超尺寸残片(上游多发货/坏分片)转正即交付坏文件
|
||
os.replace(part_path, file_path)
|
||
if write_meta:
|
||
with self._meta_semaphore:
|
||
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
|
||
return DownloadResult(
|
||
success=True,
|
||
file_path=str(file_path),
|
||
file_size=file_path.stat().st_size,
|
||
music_info=music_info,
|
||
)
|
||
elif existing > 0:
|
||
if music_info.file_size > 0 and existing > music_info.file_size:
|
||
# R5-15:超尺寸残片没有续传价值(上游多发货/坏分片),
|
||
# 丢弃从头下——否则下轮被 >= 误转正交付坏文件
|
||
self._unlink_quiet(part_path)
|
||
else:
|
||
resume_from = existing # 断点续传起点
|
||
else:
|
||
self._unlink_quiet(part_path)
|
||
|
||
# 第 91 条(P2-7):排队改用可取消等待(F91 的 acquire_cancellable)——
|
||
# 原阻塞式 with 使取消后仍要等全局 10 名额腾出,任务卡死、退款延迟
|
||
if not self._download_semaphore.acquire_cancellable(cancel_event, poll=0.5):
|
||
raise DownloadCanceled("下载已取消")
|
||
try:
|
||
headers = {}
|
||
mode = "wb"
|
||
if resume_from > 0:
|
||
headers["Range"] = f"bytes={resume_from}-"
|
||
mode = "ab"
|
||
|
||
if progress_cb is not None:
|
||
try:
|
||
progress_cb(resume_from, music_info.file_size or 0)
|
||
except Exception:
|
||
pass # 进度回调绝不影响下载主流程
|
||
resp: Optional[requests.Response] = None
|
||
try:
|
||
resp = get_http_session().get(
|
||
music_info.download_url,
|
||
stream=True,
|
||
timeout=(10, 60),
|
||
headers=headers,
|
||
)
|
||
# 服务器忽略 Range 返回 200 → 从头重写而非追加
|
||
if resume_from > 0 and resp.status_code != 206:
|
||
mode = "wb"
|
||
# F29:206 必须从我们请求的起点开始;起点错位的分片拼起来是坏文件
|
||
if resume_from > 0 and resp.status_code == 206:
|
||
cr = resp.headers.get('Content-Range') or ''
|
||
m = re.match(r'bytes\s+(\d+)-', cr.strip())
|
||
if m and int(m.group(1)) != resume_from:
|
||
self._unlink_quiet(part_path)
|
||
raise _RetryFromScratch(
|
||
f"Content-Range 起点错位({m.group(1)} != {resume_from}),已从头重新下载")
|
||
# R5-16:206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件,
|
||
# 与起点错位同口径处理:清残片从头下
|
||
if not m:
|
||
self._unlink_quiet(part_path)
|
||
raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载")
|
||
resp.raise_for_status()
|
||
# F20:下载体积硬上限(配置 500MB),超限即弃,防单文件拖垮磁盘
|
||
max_bytes = 500 * 1024 * 1024
|
||
written = resume_from if mode == 'ab' else 0
|
||
over_limit = False
|
||
with open(part_path, mode) as f:
|
||
for chunk in resp.iter_content(chunk_size=DL_CHUNK_SIZE):
|
||
if cancel_event is not None and cancel_event.is_set():
|
||
raise DownloadCanceled("下载已取消")
|
||
if chunk:
|
||
f.write(chunk)
|
||
written += len(chunk)
|
||
if written > max_bytes:
|
||
# C12:超限残片没有任何续传价值。第 91→93 条:
|
||
# Windows 无法删除打开中的文件——置标志 break,
|
||
# with 关闭后再清理(原实现在打开中 unlink 被静默吞掉)
|
||
over_limit = True
|
||
break
|
||
if progress_cb is not None:
|
||
try:
|
||
progress_cb(written, music_info.file_size or 0)
|
||
except Exception:
|
||
pass # 进度回调绝不影响下载主流程
|
||
if over_limit:
|
||
self._unlink_quiet(part_path) # 文件已关闭,清理可达
|
||
raise DownloadException(
|
||
f"文件超过大小上限 {max_bytes // (1024*1024)}MB,已中止")
|
||
except requests.HTTPError as e:
|
||
# Range 起点无效(如 CDN 环节变化):清除旧 part,从头重新下载
|
||
status = getattr(getattr(e, "response", None), "status_code", None)
|
||
if status == 416:
|
||
self._unlink_quiet(part_path)
|
||
raise _RetryFromScratch("Range 起点失效,已从头重新下载") from e
|
||
if status is not None and 400 <= status < 500 and status != 408:
|
||
# 第 97 条(P3):CDN 4xx(403 防盗链/429 风控等)是确定性
|
||
# 失败,对同一失效直链白重试只会加剧风控、拖慢失败返回
|
||
# (408 请求超时保留可重试)
|
||
self._unlink_quiet(part_path)
|
||
raise DownloadException(
|
||
f"下载失败(HTTP {status}),下载链接可能已失效") from e
|
||
raise
|
||
finally:
|
||
if resp is not None:
|
||
resp.close()
|
||
finally:
|
||
# 第 91 条(P2-7):与 acquire_cancellable 配对释放(原 with 语义)
|
||
self._download_semaphore.release()
|
||
|
||
# 完整性校验(F29:已知期望大小时必须精确相等;未知期望时至少非空)
|
||
actual_size = part_path.stat().st_size
|
||
if music_info.file_size > 0:
|
||
complete = (actual_size == music_info.file_size)
|
||
else:
|
||
complete = (actual_size > 0)
|
||
if not complete:
|
||
# 保留 .part 以便断点续传;下次续传从现有字节继续
|
||
last_error = (
|
||
f"下载不完整: 期望 {music_info.file_size or '未知'} 字节, "
|
||
f"实际 {actual_size} 字节"
|
||
)
|
||
if attempt < retries:
|
||
next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条)
|
||
continue
|
||
|
||
# 第 97 条(P3):转正前内容嗅探——未知期望尺寸("非空即通过"口径)
|
||
# 时 CDN 错误页/风控 JSON 曾可被存成音频交付
|
||
if music_info.file_size <= 0 and not _sniff_audio_header(part_path):
|
||
self._unlink_quiet(part_path)
|
||
raise DownloadException("下载内容校验失败(非音频数据)")
|
||
|
||
os.replace(part_path, file_path)
|
||
|
||
# 元数据写入移出下载信号量(CPU 操作不占网络名额),
|
||
# 但仍在文件锁内执行,保证其它请求看到的是已完成标记的最终文件
|
||
if write_meta:
|
||
# 第 97 条(P3):元数据段入口补取消检查点——已取消的任务不再
|
||
# 进入信号量排队/ffmpeg 等待(有界但可达 120s),收尾不再延迟
|
||
if cancel_event is not None and cancel_event.is_set():
|
||
raise DownloadCanceled("下载已取消")
|
||
with self._meta_semaphore:
|
||
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
|
||
|
||
return DownloadResult(
|
||
success=True,
|
||
file_path=str(file_path),
|
||
file_size=file_path.stat().st_size,
|
||
music_info=music_info,
|
||
)
|
||
|
||
except DownloadCanceled:
|
||
# 用户明确放弃:清理半截临时文件
|
||
self._unlink_quiet(part_path)
|
||
raise
|
||
except DownloadException:
|
||
# 第 94 条(P2-1):确定性失败(如体积超上限)不重试——原落泛型
|
||
# handler 会删残片后从 0 重拉,3 轮白拉 ~1.5GB
|
||
raise
|
||
except _RetryFromScratch:
|
||
# .part 已清理,直接进入下一轮从头下载
|
||
last_error = "续传起点失效,重新下载"
|
||
if attempt < retries:
|
||
next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条)
|
||
continue
|
||
except (requests.RequestException, OSError) as e:
|
||
# 网络错误:保留 .part 支持断点续传;
|
||
# F117:但若任务已被取消,.part 没有任何续传价值,一并清理
|
||
if cancel_event is not None and cancel_event.is_set():
|
||
self._unlink_quiet(part_path)
|
||
raise DownloadCanceled("下载已取消") from e
|
||
last_error = f"下载请求失败: {e}"
|
||
if attempt < retries:
|
||
next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条)
|
||
continue
|
||
except Exception as e:
|
||
last_error = f"下载过程中发生错误: {e}"
|
||
if attempt < retries:
|
||
next_wait = 0.5 * (attempt + 1) # 锁外睡眠(第 91 条)
|
||
continue
|
||
|
||
# 最后一次尝试仍失败:若任务已取消按取消收尾(F117)
|
||
if cancel_event is not None and cancel_event.is_set():
|
||
self._unlink_quiet(part_path0)
|
||
raise DownloadCanceled("下载已取消")
|
||
return DownloadResult(success=False, error_message=last_error or "下载失败")
|
||
|
||
|
||
# ============ ffmpeg 元数据写入(核心) ============
|
||
# P3-42:download_music_to_memory / download_batch_async / get_download_progress
|
||
# 三个方法已删除——全服务端与验证脚本零调用(CLI 遗留),且 to_memory 不接受
|
||
# cookies 参数、真被启用会有行为差异;如需内存下载/异步批量再按当前接口重写
|
||
|
||
def _write_metadata_with_ffmpeg(
|
||
self,
|
||
file_path: Path,
|
||
music_info: MusicInfo,
|
||
fields: Optional[List[str]] = None,
|
||
) -> bool:
|
||
"""使用 ffmpeg 写入音频元数据
|
||
|
||
处理: title/artist/album/track + 封面(如果是 mp3/flac/m4a/mp4)
|
||
fields: 可选字段列表 ['title','artist','album','cover','lyrics','date'];
|
||
None 表示全部写入。
|
||
|
||
Returns:
|
||
是否写入成功(失败不影响主流程)
|
||
"""
|
||
ffmpeg_path = self._resolve_ffmpeg()
|
||
if not ffmpeg_path:
|
||
logger.warning("跳过元数据写入: ffmpeg 不可用")
|
||
return False
|
||
|
||
file_ext = file_path.suffix.lower()
|
||
if file_ext not in METADATA_FORMATS:
|
||
logger.warning(f"跳过元数据写入: 不支持的格式 {file_ext}")
|
||
return False
|
||
|
||
if not file_path.exists():
|
||
logger.warning(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
|
||
cover_ok = False # F114:创建成功与否与"路径是否需要清理"分离管理
|
||
|
||
try:
|
||
# 下载封面到临时文件(走共享缓存:与 ZIP 打包共用一次网络请求)
|
||
include_cover = fields is None or "cover" in fields
|
||
if include_cover and music_info.pic_url:
|
||
cover_data = fetch_cover_bytes(music_info.pic_url)
|
||
if cover_data:
|
||
try:
|
||
fd, cover_tmp = tempfile.mkstemp(suffix=".jpg", dir=str(self.download_dir))
|
||
os.close(fd)
|
||
with open(cover_tmp, "wb") as cf:
|
||
cf.write(cover_data)
|
||
cover_ok = True
|
||
except Exception as e:
|
||
# cover_tmp 保持指向已创建的文件,finally 统一清理(F114:
|
||
# 原实现置 None 会把已创建的零字节文件遗留在下载目录)
|
||
logger.warning(f"封面写盘失败,跳过封面写入: {e}")
|
||
else:
|
||
logger.warning("封面下载失败,跳过封面写入")
|
||
|
||
# 构造 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),
|
||
]
|
||
use_cover = cover_ok
|
||
# F35:OGG/Opus 的封面流 copy 不被支持(Unsupported codec id),
|
||
# 主动降级为"只写文本元数据",不再让整个写入失败
|
||
if use_cover and file_ext in (".ogg", ".opus"):
|
||
use_cover = False
|
||
logger.info("OGG/Opus 不支持封面写入,仅写入文本元数据")
|
||
if use_cover and cover_tmp:
|
||
cmd += ["-i", cover_tmp]
|
||
|
||
# 通用元数据(仅写入勾选的字段)
|
||
meta = {}
|
||
# F33:前端字段名为 name,与后端 title 统一映射
|
||
if fields is None or "title" in fields or "name" in fields:
|
||
meta["title"] = music_info.name
|
||
if fields is None or "artist" in fields:
|
||
meta["artist"] = music_info.artists
|
||
meta["album_artist"] = music_info.artists
|
||
if fields is None or "album" in fields:
|
||
meta["album"] = music_info.album
|
||
if fields is None or "date" in fields:
|
||
if music_info.publish_year:
|
||
meta["date"] = str(music_info.publish_year)
|
||
meta["year"] = str(music_info.publish_year)
|
||
if fields is None or "lyrics" in fields:
|
||
if music_info.lyric and music_info.lyric.strip():
|
||
# 去掉 LRC 时间标签,写入纯歌词
|
||
clean_lyric = re.sub(
|
||
r"\[\d{1,2}:\d{1,2}([.:]\d+)?\]", "", music_info.lyric
|
||
).strip()
|
||
if clean_lyric:
|
||
meta["lyrics"] = clean_lyric
|
||
if music_info.track_number > 0 and (fields is None or "track" in fields):
|
||
# F33:只写当前曲号;没有真实专辑总数时不虚构 tracktotal
|
||
meta["track"] = 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=use_cover)
|
||
|
||
# -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():
|
||
# 第 97 条(P3):os.replace 才是同卷原子替换——shutil.move 在目标已
|
||
# 存在时退化为 copy2+unlink,进程中断会留半截文件
|
||
os.replace(str(tmp_output), str(file_path))
|
||
return True
|
||
|
||
# P2-5:ffmpeg stderr 原文走 logger(errors='ignore' 解码后的任意 Unicode
|
||
# 在 GBK 重定向下裸 print 会崩 worker)
|
||
logger.warning(
|
||
f"ffmpeg 写入元数据失败: {(result.stderr or result.stdout or '').strip()}"
|
||
)
|
||
return False
|
||
|
||
except subprocess.TimeoutExpired:
|
||
logger.warning("ffmpeg 执行超时")
|
||
return False
|
||
except FileNotFoundError:
|
||
logger.warning(f"ffmpeg 可执行文件未找到: {ffmpeg_path}")
|
||
return False
|
||
except Exception as e:
|
||
logger.warning(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 / 编码参数
|
||
F34:不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面),
|
||
原实现 -map 0:a 会把 MP3/M4A 已有封面静默删掉。"""
|
||
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", "-c", "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)",
|
||
]
|
||
# F34:保留既有封面流(原 -map 0:a 会删掉已有封面)
|
||
return ["-map", "0", "-c", "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 入口 ============
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
from dataclasses import asdict
|
||
|
||
downloader = MusicDownloader()
|
||
|
||
# 无参数:打印模块信息
|
||
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)
|
||
|