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
@@ -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
|
||||
Reference in new issue
Block a user