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:
snowy-yang committed 2026-09-26 13:54:44 +08:00
1 parent 183f5744d8
commit ad8c30d932
12 files changed
+1367 -369

No files matched your search

+276
View File
@@ -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