Archived
refactor: 下载/标签/格式探测改用成熟库(aria2 + mutagen + filetype)
为什么:原先三处都在手写轮子——ffmpeg CLI 拼参数写标签、aiohttp 单连接手搓 下载、自己比对魔数判格式。手写部分既难测又易错;改走成熟库后测试从 144 涨到 175,并且彻底去掉了 ffmpeg 这个外部二进制依赖。 下载 → aria2c 常驻 JSON-RPC(aria2p 客户端),新增 _aria2.py - 单例后端:首次调用时拉起 aria2c,只监听 127.0.0.1 且强制随机 secret (无鉴权的 aria2 RPC 允许本机任意进程操纵队列、读写任意文件) - split=5 / max-connection-per-server=5 多连接 + 原生断点续传 - 异常语义与内置通道逐一对齐:取消/超限清残片抛 DownloadCanceled / DownloadException,任务失败/超时保留残片抛 RequestException 供续传 - 取不到 aria2c 时 get_backend() 返回 None,自动降级到内置 aiohttp 通道 (本机无 aria2c 的降级路径已实测) 标签 → mutagen 纯 Python 写入,新增 tagging.py - 取代 _write_metadata_with_ffmpeg + _build_format_args(226 行 shell 参数拼接) - MP3(ID3v2.3)/FLAC/M4A/OGG/Opus 分容器实现,无子进程、不重编码 - 就地改帧:TXXX 等未触及的标签原样保留(ffmpeg 整帧重写会丢) - 临时文件 + os.replace 原子替换,写坏不会留下半个文件 - 为什么不是 ffmpeg-python(原方案):实测它用 **kwargs 建模选项,无法表达 重复键(-metadata ×N),list 值还会被字符串化成 "['0', '1:v']";-map 尚有 内置的 stream 位置参数入口,但 -metadata 无解,且 overwrite_output=True 生成的 -y 被排到输出文件之后、可能失效 格式探测 → filetype,改 _http.py - 删除手写的 _AUDIO_MAGIC 魔数表,改用 filetype.guess() 判 MIME (audio/* 放行,video/mp4 作为容器显式放行) 配套 - Dockerfile:镜像不再装 ffmpeg,改装 aria2 - pyproject:新增 filetype / aria2p / mutagen;dev 加 httpx2(消除测试里的 StarletteDeprecationWarning,现 0 warning) - __main__.py / __init__.py / models.py:清理 ffmpeg 相关导出与文案 测试:新增 31 个用例(test_aria2.py 13、test_tagging.py 21,含最小合法 FLAC 样本构造);全套 175 passed / 0 warning
This commit is contained in:
1 parent
183f5744d8
commit
ad8c30d932
12 files changed
+1367
-369
No files matched your search
@@ -1,16 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""网易云音乐下载器(适配 ffmpeg 写入元数据)
|
||||
"""网易云音乐下载器(标签写入基于 mutagen,纯 Python)
|
||||
|
||||
提供功能:
|
||||
- 同步 / 异步下载(本地落盘 / 内存返回)
|
||||
- 批量并发下载
|
||||
- 进度查询
|
||||
- 基于 ffmpeg 的元数据 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus)
|
||||
- 基于 mutagen 的标签 + 封面写入(MP3/FLAC/M4A/MP4/OGG/Opus)
|
||||
|
||||
设计要点:
|
||||
1. ffmpeg 路径懒加载:每次写入前重新检测,支持服务启动后中途安装
|
||||
2. 显式 -f 指定输出格式 + 临时文件保留原扩展名在最后,双保险
|
||||
3. MP4(杜比全景声 EAC3)单独分支,启用 +faststart
|
||||
1. 内容嗅探交给 filetype(按魔数识别容器),不再自维护魔数表
|
||||
2. 传输通道二选一:aria2c 常驻 RPC(多连接)优先,不可用时回落内置 aiohttp 流式
|
||||
3. 标签写入按容器分派(ID3v2.3 / Vorbis comment / iTunes ilst),全部原子替换
|
||||
4. 扩展名优先使用 API 返回的 type 字段,URL/Content-Type 兜底
|
||||
"""
|
||||
|
||||
@@ -27,7 +27,6 @@ from ._concurrency import (
|
||||
get_file_lock,
|
||||
)
|
||||
from ._http import (
|
||||
_AUDIO_MAGIC,
|
||||
_SESSION_LOCAL,
|
||||
DEFAULT_UA,
|
||||
DL_CHUNK_SIZE,
|
||||
@@ -49,7 +48,6 @@ from .downloader import (
|
||||
)
|
||||
from .models import (
|
||||
API_TYPE_TO_EXT,
|
||||
EXT_TO_FFMPEG_FORMAT,
|
||||
METADATA_FORMATS,
|
||||
DownloadCanceled,
|
||||
DownloadException,
|
||||
@@ -63,9 +61,7 @@ __all__ = [
|
||||
'API_TYPE_TO_EXT',
|
||||
'DEFAULT_UA',
|
||||
'DL_CHUNK_SIZE',
|
||||
'EXT_TO_FFMPEG_FORMAT',
|
||||
'METADATA_FORMATS',
|
||||
'_AUDIO_MAGIC',
|
||||
'_COVER_CACHE',
|
||||
'_COVER_CACHE_LOCK',
|
||||
'_COVER_CACHE_MAX',
|
||||
|
||||
@@ -21,9 +21,8 @@ if __name__ == "__main__":
|
||||
# 无参数:打印模块信息
|
||||
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("支持的功能: 同步/批量下载、标签与封面写入(mutagen 纯 Python)")
|
||||
print()
|
||||
print("用法:")
|
||||
print(" python music_downloader.py <music_id> [quality] # 仅打印信息")
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""aria2c 常驻 RPC 下载后端(可选加速通道;不可用时调用方降级)。
|
||||
|
||||
为什么用常驻 RPC 而不是每个任务起一个 aria2c 进程:
|
||||
- 批量任务最多 500 首,逐首歌都付一次进程启动 + RPC 握手开销不划算;
|
||||
- 取消/查询只需一次 RPC,不必在进程表里追踪 N 个子进程。
|
||||
|
||||
安全与稳健:
|
||||
- RPC 只监听 127.0.0.1 并强制随机 secret —— 无鉴权的 aria2 RPC 允许本机任意
|
||||
进程操纵下载队列(读写任意文件),暴露到容器外等于开了一个后门;
|
||||
- aria2c 缺失 / 进程早退 / RPC 未就绪,一律返回 None 静默降级,
|
||||
由调用方回落到既有的 core.sync_http 流式通道;
|
||||
- aria2 没有「单文件体积上限」选项,故用轮询 tellStatus 实现体积守卫与取消响应。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import secrets
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from core import sync_http as requests # 同步门面(底层 aiohttp)
|
||||
|
||||
from ._http import DEFAULT_UA
|
||||
from ._log import logger
|
||||
from .models import DownloadCanceled, DownloadException
|
||||
|
||||
|
||||
class _ProcessHandle(Protocol):
|
||||
"""aria2c 子进程的最小接口。
|
||||
|
||||
只依赖本模块真正调用的方法,既避免绑死 subprocess.Popen 具体类型,
|
||||
也让测试可以用轻量替身驱动生命周期逻辑。
|
||||
"""
|
||||
|
||||
def poll(self) -> int | None: ...
|
||||
def terminate(self) -> None: ...
|
||||
def kill(self) -> None: ...
|
||||
def wait(self, timeout: float | None = None) -> int: ...
|
||||
|
||||
# aria2 可执行文件名。Docker 镜像内由 apk 提供;宿主未装则整条通道降级
|
||||
ARIA2_BIN = 'aria2c'
|
||||
|
||||
# RPC 就绪等待上限:超过即判定启动失败并降级(不拖慢首次下载)
|
||||
_RPC_READY_TIMEOUT = 8.0
|
||||
# 状态轮询间隔:取消与体积守卫的响应粒度(0.3s 足够,RPC 开销可忽略)
|
||||
_POLL_INTERVAL = 0.3
|
||||
# 单文件体积守卫默认值,与 downloader 内置通道口径一致
|
||||
_DEFAULT_MAX_BYTES = 500 * 1024 * 1024
|
||||
# 分片数与单服务器连接数:网易云直链支持 Range,多连接可提速;
|
||||
# 数值保守(aria2 默认即 5),过高易触发 CDN 风控
|
||||
_SPLIT = 5
|
||||
_MIN_SPLIT_SIZE = '1M'
|
||||
|
||||
_lock = threading.Lock()
|
||||
_backend: Aria2Backend | None = None
|
||||
_unavailable = False # 启动失败后置位,不再反复重试
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""向内核要一个空闲端口(bind 后立即释放,交给 aria2c 监听)。"""
|
||||
with socket.socket() as s:
|
||||
s.bind(('127.0.0.1', 0))
|
||||
return int(s.getsockname()[1])
|
||||
|
||||
|
||||
class Aria2Backend:
|
||||
"""aria2c RPC 后端。请通过 get_backend() 获取单例,不要直接构造。"""
|
||||
|
||||
def __init__(self, api: Any, proc: _ProcessHandle, port: int) -> None:
|
||||
self._api = api
|
||||
self._proc = proc
|
||||
self.port = port
|
||||
self._closed = False
|
||||
|
||||
# ---------- 生命周期 ----------
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭 RPC 客户端并终止 aria2c 进程(幂等)。"""
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
try:
|
||||
self._api.client.close()
|
||||
except Exception:
|
||||
pass # 客户端关闭失败不影响后续进程回收
|
||||
if self._proc.poll() is None:
|
||||
self._proc.terminate()
|
||||
try:
|
||||
self._proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
self._proc.wait(timeout=5)
|
||||
|
||||
# ---------- 下载 ----------
|
||||
|
||||
def fetch(
|
||||
self,
|
||||
url: str,
|
||||
target: Path,
|
||||
*,
|
||||
ua: str = DEFAULT_UA,
|
||||
referer: str | None = None,
|
||||
cancel_event: threading.Event | None = None,
|
||||
progress_cb: Callable[[int, int], None] | None = None,
|
||||
max_bytes: int = _DEFAULT_MAX_BYTES,
|
||||
timeout: float = 600.0,
|
||||
) -> None:
|
||||
"""把 url 下载到 target(落地即最终字节,含断点续传)。
|
||||
|
||||
Raises:
|
||||
DownloadCanceled: cancel_event 置位(残片一并删除)
|
||||
DownloadException: 体积超限(残片一并删除)、aria2 报告任务失败、总超时
|
||||
requests.RequestException: RPC 抖动等可重试的网络类失败(保留残片续传)
|
||||
"""
|
||||
options = {
|
||||
'dir': str(target.parent),
|
||||
'out': target.name,
|
||||
'continue': 'true', # 断点续传(控制文件 target.aria2 与残片同放)
|
||||
'auto-file-renaming': 'false', # 禁止另存为 target.1,否则与 .part 语义冲突
|
||||
'allow-overwrite': 'true',
|
||||
'split': str(_SPLIT),
|
||||
'max-connection-per-server': str(_SPLIT),
|
||||
'min-split-size': _MIN_SPLIT_SIZE,
|
||||
'user-agent': ua,
|
||||
'summary-interval': '0',
|
||||
'quiet': 'true',
|
||||
}
|
||||
if referer:
|
||||
options['referer'] = referer
|
||||
|
||||
try:
|
||||
download = self._api.add_uris([url], options=options)
|
||||
except Exception as e:
|
||||
raise requests.RequestException(f'aria2 提交任务失败: {e}') from e
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
self._abort(download, drop_files=True)
|
||||
raise DownloadCanceled('下载已取消')
|
||||
try:
|
||||
download.update()
|
||||
except Exception as e:
|
||||
# RPC 抖动按可重试的网络失败上报,交给外层退避重试
|
||||
raise requests.RequestException(f'aria2 状态查询失败: {e}') from e
|
||||
|
||||
done = int(download.completed_length or 0)
|
||||
total = int(download.total_length or 0)
|
||||
if progress_cb is not None:
|
||||
try:
|
||||
progress_cb(done, total)
|
||||
except Exception:
|
||||
pass # 进度回调绝不影响下载主流程
|
||||
if done > max_bytes:
|
||||
self._abort(download, drop_files=True)
|
||||
raise DownloadException(
|
||||
f'文件超过大小上限 {max_bytes // (1024 * 1024)}MB,已中止')
|
||||
if download.is_complete:
|
||||
self._drop_control_file(target)
|
||||
return
|
||||
if download.has_failed:
|
||||
# 保留残片与控制文件,等待外层重试时续传
|
||||
raise requests.RequestException(
|
||||
f'aria2 下载失败: {download.error_message or download.status}')
|
||||
if time.monotonic() > deadline:
|
||||
self._abort(download, drop_files=False)
|
||||
raise requests.RequestException('aria2 下载超时')
|
||||
time.sleep(_POLL_INTERVAL)
|
||||
|
||||
# ---------- 内部 ----------
|
||||
|
||||
def _abort(self, download: Any, *, drop_files: bool) -> None:
|
||||
"""强制移除任务。drop_files=True 时连同已下载字节一起删除。"""
|
||||
try:
|
||||
self._api.remove([download], force=True, files=drop_files)
|
||||
except Exception as e:
|
||||
logger.warning(f'终止 aria2 任务失败(不影响主流程): {e}')
|
||||
|
||||
@staticmethod
|
||||
def _drop_control_file(target: Path) -> None:
|
||||
"""清理 aria2 控制文件(正常情况下 aria2 已自行删除,这里兜底)。"""
|
||||
ctrl = target.with_name(target.name + '.aria2')
|
||||
try:
|
||||
ctrl.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def get_backend() -> Aria2Backend | None:
|
||||
"""返回 aria2 后端单例;aria2c 不可用/启动失败时返回 None(调用方降级)。
|
||||
|
||||
首次调用尝试拉起常驻进程,之后命中缓存;失败结果进程内记忆,不反复重试。
|
||||
"""
|
||||
global _backend, _unavailable
|
||||
if _unavailable:
|
||||
return None
|
||||
if _backend is not None:
|
||||
return _backend
|
||||
with _lock:
|
||||
if _unavailable:
|
||||
return None
|
||||
if _backend is None:
|
||||
_backend = _try_start()
|
||||
if _backend is None:
|
||||
_unavailable = True
|
||||
return _backend
|
||||
|
||||
|
||||
def _try_start() -> Aria2Backend | None:
|
||||
"""拉起 aria2c --enable-rpc 并等待 RPC 就绪;任何失败都返回 None。"""
|
||||
exe = shutil.which(ARIA2_BIN)
|
||||
if not exe:
|
||||
logger.info('未检测到 aria2c,下载走内置流式通道')
|
||||
return None
|
||||
try:
|
||||
import aria2p
|
||||
except ImportError as e:
|
||||
logger.info(f'aria2p 不可用,下载走内置流式通道: {e}')
|
||||
return None
|
||||
|
||||
port = _free_port()
|
||||
secret = secrets.token_urlsafe(24)
|
||||
cmd = [
|
||||
exe,
|
||||
'--enable-rpc',
|
||||
f'--rpc-listen-port={port}',
|
||||
f'--rpc-secret={secret}',
|
||||
'--rpc-listen-all=false',
|
||||
'--rpc-allow-origin-all=false',
|
||||
'--no-conf',
|
||||
'--quiet=true',
|
||||
]
|
||||
try:
|
||||
# exe 来自 shutil.which,参数全部由本模块构造,不经任何用户输入
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except OSError as e:
|
||||
logger.warning(f'aria2c 启动失败,降级内置下载: {e}')
|
||||
return None
|
||||
|
||||
client = aria2p.Client(host='http://127.0.0.1', port=port, secret=secret)
|
||||
deadline = time.monotonic() + _RPC_READY_TIMEOUT
|
||||
while True:
|
||||
if proc.poll() is not None:
|
||||
logger.warning(f'aria2c 进程提前退出(code={proc.returncode}),降级内置下载')
|
||||
return None
|
||||
try:
|
||||
client.get_version()
|
||||
break
|
||||
except Exception:
|
||||
if time.monotonic() > deadline:
|
||||
logger.warning(f'aria2c RPC {_RPC_READY_TIMEOUT:.0f}s 内未就绪,降级内置下载')
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
return None
|
||||
time.sleep(0.2)
|
||||
|
||||
atexit.register(_shutdown)
|
||||
logger.info(f'aria2c 常驻 RPC 已就绪(127.0.0.1:{port},{_SPLIT} 连接/文件)')
|
||||
return Aria2Backend(aria2p.API(client), proc, port)
|
||||
|
||||
|
||||
def _shutdown() -> None:
|
||||
"""进程退出时回收 aria2c(atexit 只注册一次)。"""
|
||||
global _backend
|
||||
if _backend is not None:
|
||||
_backend.close()
|
||||
_backend = None
|
||||
+17
-13
@@ -8,6 +8,8 @@ from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import filetype
|
||||
|
||||
from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests)
|
||||
|
||||
# ============ HTTP 连接复用(提速核心) ============
|
||||
@@ -27,26 +29,28 @@ DL_CHUNK_SIZE = 256 * 1024
|
||||
_SESSION_LOCAL = threading.local()
|
||||
|
||||
|
||||
# 下载内容嗅探的音频魔数(未知期望尺寸时唯一的内容防线)
|
||||
_AUDIO_MAGIC = (b'ID3', b'fLaC', b'OggS', b'RIFF')
|
||||
# M4A/MP4 音频在 filetype 里被判为 video/mp4——mp4 是复合容器,仅凭魔数无法
|
||||
# 区分音频轨与视频轨,故显式放行该 mime。
|
||||
_CONTAINER_MIMES = frozenset({'video/mp4'})
|
||||
|
||||
|
||||
def _sniff_audio_header(path) -> bool:
|
||||
"""读取文件头做基础音频魔数校验:MP3(ID3/裸帧)、FLAC、OGG/Opus、WAV(RIFF)、
|
||||
M4A/MP4(ftyp)。未知期望尺寸的下载走"非空即通过"保留口径,本函数补上内容
|
||||
防线——CDN 错误页/风控 JSON 不再能被存成音频交付。"""
|
||||
"""按魔数判定文件是否为音频容器(MP3/FLAC/OGG/Opus/WAV/M4A/MP4)。
|
||||
|
||||
未知期望尺寸的下载走"非空即通过"保留口径,本函数补上内容防线——
|
||||
CDN 错误页/风控 JSON 不再能被存成音频交付。
|
||||
|
||||
识别交给 filetype(内置完整类型表,覆盖各格式的多种帧头变体),
|
||||
不再自行维护魔数前缀与 ftyp 偏移;对空文件/未知格式返回 False。
|
||||
"""
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
head = f.read(16)
|
||||
kind = filetype.guess(str(path))
|
||||
except OSError:
|
||||
return False
|
||||
if not head:
|
||||
if kind is None:
|
||||
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
|
||||
mime = (kind.mime or '').lower()
|
||||
return mime.startswith('audio/') or mime in _CONTAINER_MIMES
|
||||
|
||||
|
||||
def get_http_session() -> requests.Session:
|
||||
|
||||
+166
-328
@@ -8,9 +8,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -19,6 +16,7 @@ from core import sync_http as requests # 同步门面(底层 aiohttp,替代
|
||||
from core.async_bridge import run_sync
|
||||
from music_api import APIException, NeteaseAPI, load_cookies
|
||||
|
||||
from ._aria2 import Aria2Backend, get_backend
|
||||
from ._concurrency import (
|
||||
CountingSemaphore,
|
||||
get_file_lock,
|
||||
@@ -36,14 +34,16 @@ from .cover import (
|
||||
)
|
||||
from .models import (
|
||||
API_TYPE_TO_EXT,
|
||||
EXT_TO_FFMPEG_FORMAT,
|
||||
METADATA_FORMATS,
|
||||
DownloadCanceled,
|
||||
DownloadException,
|
||||
DownloadResult,
|
||||
MusicInfo,
|
||||
_RetryFromScratch,
|
||||
)
|
||||
from .tagging import build_tags, write_tags
|
||||
|
||||
# 单文件下载体积硬上限:超限即弃,防止单文件拖垮磁盘
|
||||
_MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024
|
||||
|
||||
# ============ 核心实现 ============
|
||||
|
||||
@@ -59,15 +59,13 @@ class MusicDownloader:
|
||||
self,
|
||||
download_dir: str = "downloads",
|
||||
max_concurrent: int = 3,
|
||||
ffmpeg_path: str | None = None,
|
||||
global_max_concurrent: int = 10,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
download_dir: 下载目录
|
||||
max_concurrent: 单个批量任务内的最大并发下载数
|
||||
ffmpeg_path: ffmpeg 可执行文件路径,为 None 时启动时检测一次
|
||||
global_max_concurrent: 整个服务同时下载的文件数(含 ffmpeg 元数据写入)。
|
||||
global_max_concurrent: 整个服务同时下载的文件数(含标签写入)。
|
||||
所有请求共享该信号量,防止大量用户同时下载导致内存/CPU 耗尽。
|
||||
"""
|
||||
self.download_dir = Path(download_dir)
|
||||
@@ -80,15 +78,10 @@ class MusicDownloader:
|
||||
# (带计数,供前端实时展示排队情况)
|
||||
self._download_semaphore = CountingSemaphore(global_max_concurrent)
|
||||
|
||||
# ffmpeg 元数据写入专用信号量(CPU 密集操作):
|
||||
# 与下载分离限流 —— 元数据写满时只阻塞元数据,不占下载名额、不拖慢其它文件的传输
|
||||
# 标签写入专用信号量:写标签会重写整个文件(IO 密集),
|
||||
# 与下载分离限流 —— 写满时只阻塞后来者,不占下载名额、不拖慢其它文件的传输
|
||||
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,写入元数据功能将不可用")
|
||||
self._tag_semaphore = threading.BoundedSemaphore(max(1, min(4, cpu_count)))
|
||||
|
||||
# ----------- 工具方法 -----------
|
||||
|
||||
@@ -142,18 +135,6 @@ class MusicDownloader:
|
||||
|
||||
return ".mp3"
|
||||
|
||||
def _resolve_ffmpeg(self) -> str | None:
|
||||
"""懒加载解析 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:
|
||||
"""安静地删除文件,忽略不存在 / 权限错误"""
|
||||
@@ -328,6 +309,11 @@ class MusicDownloader:
|
||||
self._unlink_quiet(part_path0)
|
||||
raise DownloadCanceled("下载已取消")
|
||||
|
||||
# part_path 是 file_path 的 .part 别名。必须在进入下载 try 之前绑定:
|
||||
# except 分支(取消/网络错误)要清理 .part,若此处未赋值,异常处理器
|
||||
# 会以 NameError 二次崩溃、掩盖原始异常
|
||||
part_path = part_path0
|
||||
|
||||
# 同一目标文件的下载者串行化(不同文件之间仍然并行)
|
||||
with get_file_lock(file_path):
|
||||
try:
|
||||
@@ -345,8 +331,8 @@ class MusicDownloader:
|
||||
else:
|
||||
# 缓存命中也尊重本次的元数据请求(重跑一次 ffmpeg,幂等)
|
||||
if write_meta:
|
||||
with self._meta_semaphore:
|
||||
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
|
||||
with self._tag_semaphore:
|
||||
self._write_metadata(file_path, music_info, meta_fields)
|
||||
return DownloadResult(
|
||||
success=True,
|
||||
file_path=str(file_path),
|
||||
@@ -354,7 +340,7 @@ class MusicDownloader:
|
||||
music_info=music_info,
|
||||
)
|
||||
|
||||
part_path = file_path.with_name(file_path.name + ".part")
|
||||
# part_path 已在文件锁外绑定为 part_path0(同一路径),此处不再重复计算
|
||||
resume_from = 0
|
||||
if part_path.exists():
|
||||
existing = part_path.stat().st_size
|
||||
@@ -364,8 +350,8 @@ class MusicDownloader:
|
||||
# 改 >= 为 ==——超尺寸残片(上游多发货/坏分片)转正即交付坏文件
|
||||
os.replace(part_path, file_path)
|
||||
if write_meta:
|
||||
with self._meta_semaphore:
|
||||
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
|
||||
with self._tag_semaphore:
|
||||
self._write_metadata(file_path, music_info, meta_fields)
|
||||
return DownloadResult(
|
||||
success=True,
|
||||
file_path=str(file_path),
|
||||
@@ -387,85 +373,15 @@ class MusicDownloader:
|
||||
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: requests.SyncResponse | None = 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"
|
||||
# 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}),已从头重新下载")
|
||||
# 206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件,
|
||||
# 与起点错位同口径处理:清残片从头下
|
||||
if not m:
|
||||
self._unlink_quiet(part_path)
|
||||
raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载")
|
||||
resp.raise_for_status()
|
||||
# 下载体积硬上限(配置 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:
|
||||
# 超限残片没有任何续传价值。
|
||||
# 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:
|
||||
# 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()
|
||||
aria2_backend = get_backend()
|
||||
if aria2_backend is not None:
|
||||
# aria2c 通道:多连接 + 原生断点续传
|
||||
self._fetch_via_aria2(aria2_backend, music_info, part_path,
|
||||
cancel_event, progress_cb)
|
||||
else:
|
||||
# 内置通道:aiohttp 流式落盘 + Range 起点校验
|
||||
self._fetch_via_http(music_info, part_path, resume_from,
|
||||
cancel_event, progress_cb)
|
||||
finally:
|
||||
# 与 acquire_cancellable 配对释放(原 with 语义)
|
||||
self._download_semaphore.release()
|
||||
@@ -501,8 +417,8 @@ class MusicDownloader:
|
||||
# 进入信号量排队/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)
|
||||
with self._tag_semaphore:
|
||||
self._write_metadata(file_path, music_info, meta_fields)
|
||||
|
||||
return DownloadResult(
|
||||
success=True,
|
||||
@@ -548,229 +464,151 @@ class MusicDownloader:
|
||||
return DownloadResult(success=False, error_message=last_error or "下载失败")
|
||||
|
||||
|
||||
# ============ ffmpeg 元数据写入(核心) ============
|
||||
# ----------- 传输通道(二选一,产物都是 part_path) -----------
|
||||
|
||||
def _fetch_via_http(
|
||||
self,
|
||||
music_info: MusicInfo,
|
||||
part_path: Path,
|
||||
resume_from: int,
|
||||
cancel_event: threading.Event | None,
|
||||
progress_cb,
|
||||
) -> None:
|
||||
"""内置流式通道:core.sync_http 逐块落盘,支持 Range 断点续传。
|
||||
|
||||
Raises:
|
||||
DownloadCanceled: 取消事件置位
|
||||
DownloadException: 体积超限、CDN 4xx 等确定性失败
|
||||
_RetryFromScratch: 续传起点失效(残片已清,外层从头重下)
|
||||
"""
|
||||
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: requests.SyncResponse | None = 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"
|
||||
# 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}),已从头重新下载")
|
||||
# 206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件,
|
||||
# 与起点错位同口径处理:清残片从头下
|
||||
if not m:
|
||||
self._unlink_quiet(part_path)
|
||||
raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载")
|
||||
resp.raise_for_status()
|
||||
# 下载体积硬上限(配置 500MB),超限即弃,防单文件拖垮磁盘
|
||||
max_bytes = _MAX_DOWNLOAD_BYTES
|
||||
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:
|
||||
# 超限残片没有任何续传价值。
|
||||
# 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:
|
||||
# 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()
|
||||
|
||||
def _fetch_via_aria2(
|
||||
self,
|
||||
backend: Aria2Backend,
|
||||
music_info: MusicInfo,
|
||||
part_path: Path,
|
||||
cancel_event: threading.Event | None,
|
||||
progress_cb,
|
||||
) -> None:
|
||||
"""aria2c 通道:多连接加速 + 原生断点续传。
|
||||
|
||||
异常语义与内置通道对齐:取消抛 DownloadCanceled、体积超限抛
|
||||
DownloadException(均清残片),网络类失败抛 RequestException
|
||||
(保留残片,由外层退避重试续传)。
|
||||
"""
|
||||
backend.fetch(
|
||||
music_info.download_url,
|
||||
part_path,
|
||||
cancel_event=cancel_event,
|
||||
progress_cb=progress_cb,
|
||||
max_bytes=_MAX_DOWNLOAD_BYTES,
|
||||
)
|
||||
|
||||
|
||||
# ============ 元数据写入(mutagen,纯 Python) ============
|
||||
# download_music_to_memory / download_batch_async / get_download_progress
|
||||
# 三个方法已删除——全服务端与验证脚本零调用(CLI 遗留),且 to_memory 不接受
|
||||
# cookies 参数、真被启用会有行为差异;如需内存下载/异步批量再按当前接口重写
|
||||
|
||||
def _write_metadata_with_ffmpeg(
|
||||
def _write_metadata(
|
||||
self,
|
||||
file_path: Path,
|
||||
music_info: MusicInfo,
|
||||
fields: list[str] | None = None,
|
||||
) -> bool:
|
||||
"""使用 ffmpeg 写入音频元数据
|
||||
"""写入音频标签与封面(mutagen);失败不影响交付,返回是否成功。
|
||||
|
||||
处理: title/artist/album/track + 封面(如果是 mp3/flac/m4a/mp4)
|
||||
fields: 可选字段列表 ['title','artist','album','cover','lyrics','date'];
|
||||
None 表示全部写入。
|
||||
Args:
|
||||
file_path: 已转正的音频文件
|
||||
music_info: 歌曲信息(标题/歌手/专辑/年份/曲号/歌词/封面地址)
|
||||
fields: 要写入的字段列表,None 表示全部写入
|
||||
|
||||
Returns:
|
||||
是否写入成功(失败不影响主流程)
|
||||
封面走共享缓存(与 ZIP 打包共用一次网络请求);各容器的落盘细节见
|
||||
tagging.py——按扩展名分派到 ID3v2.3 / Vorbis comment / iTunes ilst。
|
||||
"""
|
||||
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: str | None = None
|
||||
cover_ok = False # 创建成功与否与"路径是否需要清理"分离管理
|
||||
|
||||
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 统一清理
|
||||
# 原实现置 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
|
||||
# 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 = {}
|
||||
# 前端字段名为 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):
|
||||
# 只写当前曲号;没有真实专辑总数时不虚构 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():
|
||||
# os.replace 才是同卷原子替换——shutil.move 在目标已
|
||||
# 存在时退化为 copy2+unlink,进程中断会留半截文件
|
||||
os.replace(str(tmp_output), str(file_path))
|
||||
return True
|
||||
|
||||
# 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 / 编码参数
|
||||
不写封面时用 -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)",
|
||||
]
|
||||
# 保留既有封面流(原 -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"]
|
||||
include_cover = fields is None or "cover" in fields
|
||||
cover: bytes | None = None
|
||||
if include_cover and music_info.pic_url:
|
||||
cover = fetch_cover_bytes(music_info.pic_url)
|
||||
if not cover:
|
||||
logger.warning("封面下载失败,跳过封面写入")
|
||||
return write_tags(file_path, build_tags(music_info, fields), cover)
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""数据模型、异常与 ffmpeg 格式常量。
|
||||
"""数据模型、异常与格式常量。
|
||||
|
||||
(自 music_downloader.py 拆出,行为与原实现一致。)
|
||||
"""
|
||||
@@ -82,16 +82,5 @@ API_TYPE_TO_EXT = {
|
||||
}
|
||||
|
||||
|
||||
# 文件扩展名 -> ffmpeg 输出 muxer 名称
|
||||
EXT_TO_FFMPEG_FORMAT = {
|
||||
".mp3": "mp3",
|
||||
".flac": "flac",
|
||||
".m4a": "mp4",
|
||||
".mp4": "mp4",
|
||||
".ogg": "ogg",
|
||||
".opus": "opus",
|
||||
}
|
||||
|
||||
|
||||
# 支持元数据写入的扩展名
|
||||
# 支持元数据写入的扩展名(tagging.py 按此表分派到各容器的标签写入器)
|
||||
METADATA_FORMATS = {".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".opus"}
|
||||
@@ -0,0 +1,240 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""音频标签与封面写入(mutagen 纯 Python 实现,不需要 ffmpeg 命令)。
|
||||
|
||||
为什么不再用 ffmpeg CLI:本工程的元数据写入只做「改标签」,不做任何重编码,
|
||||
而 ffmpeg 命令行必须出现**重复的** `-metadata` / `-map` 参数;ffmpeg-python 用
|
||||
`**kwargs` 字典建模这些选项,结构上无法表达重复键(实测把列表值字符串化成
|
||||
`"['0', '1:v']"`),绕一圈仍需手写命令拼接。改用 mutagen 后整段变成纯 Python
|
||||
调用,部署镜像也不必再装 ffmpeg。
|
||||
|
||||
覆盖容器:
|
||||
- MP3 ID3v2.3(TIT2 / TPE1 / TPE2 / TALB / TDRC / TRCK / USLT / APIC)
|
||||
- FLAC Vorbis comment(title / artist / albumartist / …)+ Picture 块
|
||||
- M4A / MP4 iTunes ilst(©nam / ©ART / aART / ©alb / ©day / trkn / ©lyr)+ covr
|
||||
- OGG / Opus Vorbis comment + METADATA_BLOCK_PICTURE(base64 编码的 Picture 块)
|
||||
|
||||
写入一律走 mutagen 的原子替换(临时文件 + os.replace),中途失败不会留下半截文件;
|
||||
且是「就地改帧」,原有未触及的标签(TXXX 等)会被保留。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ._log import logger
|
||||
from .models import METADATA_FORMATS, MusicInfo
|
||||
|
||||
# 封面类型:ID3 APIC / FLAC Picture 的 picture type 3 = Cover (front)
|
||||
_PICTURE_TYPE_COVER_FRONT = 3
|
||||
_COVER_DESC = 'Cover (front)'
|
||||
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
# LRC 时间标签([mm:ss] / [mm:ss.xx] / [mm:ss:xx])
|
||||
_LRC_TIME_TAG = re.compile(r'\[\d{1,2}:\d{1,2}([.:]\d+)?\]')
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagSet:
|
||||
"""与容器无关的标签中间表示;None 表示本次不写该字段。"""
|
||||
|
||||
title: str | None = None
|
||||
artist: str | None = None
|
||||
album: str | None = None
|
||||
album_artist: str | None = None
|
||||
date: str | None = None
|
||||
track: int | None = None
|
||||
lyrics: str | None = None
|
||||
|
||||
|
||||
def build_tags(info: MusicInfo, fields: list[str] | None) -> TagSet:
|
||||
"""按前端勾选的字段构造标签集合;fields 为 None 表示全部写入。"""
|
||||
def want(*names: str) -> bool:
|
||||
return fields is None or any(n in fields for n in names)
|
||||
|
||||
tags = TagSet()
|
||||
if want('title', 'name'):
|
||||
tags.title = info.name or None
|
||||
if want('artist'):
|
||||
tags.artist = info.artists or None
|
||||
tags.album_artist = info.artists or None
|
||||
if want('album'):
|
||||
tags.album = info.album or None
|
||||
if want('date') and info.publish_year:
|
||||
tags.date = str(info.publish_year)
|
||||
if want('lyrics') and info.lyric:
|
||||
# 写入纯歌词:去掉 LRC 时间标签
|
||||
tags.lyrics = _LRC_TIME_TAG.sub('', info.lyric).strip() or None
|
||||
if want('track') and info.track_number > 0:
|
||||
# 只写当前曲号;没有真实专辑总数时不虚构 tracktotal
|
||||
tags.track = info.track_number
|
||||
return tags
|
||||
|
||||
|
||||
def write_tags(path: Path, tags: TagSet, cover: bytes | None = None) -> bool:
|
||||
"""把标签与封面写入音频文件;失败仅记日志并返回 False(不影响交付主流程)。"""
|
||||
ext = path.suffix.lower()
|
||||
writer = _WRITERS.get(ext)
|
||||
if writer is None:
|
||||
logger.warning(f"跳过元数据写入: 不支持的格式 {ext}")
|
||||
return False
|
||||
if not path.exists():
|
||||
logger.warning(f"跳过元数据写入: 文件不存在 {path}")
|
||||
return False
|
||||
try:
|
||||
writer(path, tags, cover)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"写入元数据失败({ext}): {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ---------- 各容器实现 ----------
|
||||
|
||||
def _write_mp3(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||||
from mutagen.id3 import (
|
||||
APIC,
|
||||
ID3,
|
||||
TALB,
|
||||
TDRC,
|
||||
TIT2,
|
||||
TPE1,
|
||||
TPE2,
|
||||
TRCK,
|
||||
USLT,
|
||||
ID3NoHeaderError,
|
||||
)
|
||||
|
||||
try:
|
||||
audio = ID3(str(path))
|
||||
except ID3NoHeaderError:
|
||||
audio = ID3() # 无 ID3 头的裸音频:新建标签区
|
||||
if tags.title:
|
||||
audio['TIT2'] = TIT2(encoding=3, text=[tags.title])
|
||||
if tags.artist:
|
||||
audio['TPE1'] = TPE1(encoding=3, text=[tags.artist])
|
||||
if tags.album_artist:
|
||||
audio['TPE2'] = TPE2(encoding=3, text=[tags.album_artist])
|
||||
if tags.album:
|
||||
audio['TALB'] = TALB(encoding=3, text=[tags.album])
|
||||
if tags.date:
|
||||
audio['TDRC'] = TDRC(encoding=3, text=[tags.date])
|
||||
if tags.track:
|
||||
audio['TRCK'] = TRCK(encoding=3, text=[str(tags.track)])
|
||||
if tags.lyrics:
|
||||
audio['USLT'] = USLT(encoding=3, lang='eng', desc='', text=tags.lyrics)
|
||||
if cover:
|
||||
audio['APIC'] = APIC(encoding=3, mime=_cover_mime(cover),
|
||||
type=_PICTURE_TYPE_COVER_FRONT,
|
||||
desc=_COVER_DESC, data=cover)
|
||||
# ID3v2.3 与 ffmpeg 时代的 -id3v2_version 3 保持一致(mutagen 会自动把
|
||||
# TDRC 降级为 TYER/TDAT);不写 v1 标签以免在文件尾追加 128 字节
|
||||
audio.save(str(path), v2_version=3)
|
||||
|
||||
|
||||
def _write_flac(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||||
from mutagen.flac import FLAC
|
||||
|
||||
audio = FLAC(str(path))
|
||||
_set_vorbis_comments(audio, tags)
|
||||
if cover:
|
||||
audio.clear_pictures()
|
||||
audio.add_picture(_make_picture(cover))
|
||||
audio.save()
|
||||
|
||||
|
||||
def _write_m4a(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
|
||||
audio = MP4(str(path))
|
||||
mapping = {
|
||||
'\xa9nam': tags.title,
|
||||
'\xa9ART': tags.artist,
|
||||
'aART': tags.album_artist,
|
||||
'\xa9alb': tags.album,
|
||||
'\xa9day': tags.date,
|
||||
'\xa9lyr': tags.lyrics,
|
||||
}
|
||||
for key, value in mapping.items():
|
||||
if value:
|
||||
audio[key] = [value]
|
||||
if tags.track:
|
||||
audio['trkn'] = [(tags.track, 0)] # 不虚构 tracktotal
|
||||
if cover:
|
||||
image_format = (MP4Cover.FORMAT_PNG if cover.startswith(_PNG_MAGIC)
|
||||
else MP4Cover.FORMAT_JPEG)
|
||||
audio['covr'] = [MP4Cover(cover, imageformat=image_format)]
|
||||
audio.save()
|
||||
|
||||
|
||||
def _write_ogg(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||||
_write_ogg_stream(path, tags, cover, opus=False)
|
||||
|
||||
|
||||
def _write_opus(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||||
_write_ogg_stream(path, tags, cover, opus=True)
|
||||
|
||||
|
||||
def _write_ogg_stream(path: Path, tags: TagSet, cover: bytes | None, *,
|
||||
opus: bool) -> None:
|
||||
from mutagen.oggopus import OggOpus
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
|
||||
audio: Any = OggOpus(str(path)) if opus else OggVorbis(str(path))
|
||||
_set_vorbis_comments(audio, tags)
|
||||
if cover:
|
||||
# Ogg 系把封面按 METADATA_BLOCK_PICTURE 规范存成 base64 的 Picture 块
|
||||
audio['metadata_block_picture'] = [
|
||||
base64.b64encode(_make_picture(cover).write()).decode('ascii')]
|
||||
audio.save()
|
||||
|
||||
|
||||
# ---------- 小工具 ----------
|
||||
|
||||
def _cover_mime(cover: bytes) -> str:
|
||||
return 'image/png' if cover.startswith(_PNG_MAGIC) else 'image/jpeg'
|
||||
|
||||
|
||||
def _make_picture(cover: bytes) -> Any:
|
||||
from mutagen.flac import Picture
|
||||
|
||||
pic = Picture()
|
||||
pic.type = _PICTURE_TYPE_COVER_FRONT
|
||||
pic.mime = _cover_mime(cover)
|
||||
pic.desc = _COVER_DESC
|
||||
pic.data = cover
|
||||
return pic
|
||||
|
||||
|
||||
def _set_vorbis_comments(audio: Any, tags: TagSet) -> None:
|
||||
"""FLAC 与 Ogg 共用 Vorbis comment 字段名。"""
|
||||
mapping = {
|
||||
'title': tags.title,
|
||||
'artist': tags.artist,
|
||||
'albumartist': tags.album_artist,
|
||||
'album': tags.album,
|
||||
'date': tags.date,
|
||||
}
|
||||
for key, value in mapping.items():
|
||||
if value:
|
||||
audio[key] = [value]
|
||||
if tags.track:
|
||||
audio['tracknumber'] = [str(tags.track)]
|
||||
if tags.lyrics:
|
||||
audio['lyrics'] = [tags.lyrics]
|
||||
|
||||
|
||||
_WRITERS = {
|
||||
'.mp3': _write_mp3,
|
||||
'.flac': _write_flac,
|
||||
'.m4a': _write_m4a,
|
||||
'.mp4': _write_m4a,
|
||||
'.ogg': _write_ogg,
|
||||
'.opus': _write_opus,
|
||||
}
|
||||
|
||||
# 与 models 的类型表保持同源:新增容器必须两边同时登记
|
||||
assert set(_WRITERS) == set(METADATA_FORMATS), '标签写入表与 METADATA_FORMATS 不一致'
|
||||
Reference in new issue
Block a user