This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/music_downloader/_aria2.py
T
snowy-yang 69a50c2c31 refactor: worker 子系统全面异步化,拆掉同步转异步的桥接层
拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(HTTP 线 B2 见前序提交)。
worker 侧此前是「起线程跑同步函数、函数体内部再用 asyncio.to_thread 把活塞给
另一条循环」——线程与协程互等,取消/暂停只能靠轮询。本轮把 worker 自身变成
协程,由 _start_task 直接挂到应用事件循环上,桥接层随之失去存在理由。

改动(按子系统):
- music_downloader:downloader / _aria2 / _concurrency / cover / _http /
  __main__ 全链路协程化。per-file 锁改 asyncio.Lock;计数闸改
  「active 计数 + await asyncio.sleep(0.5)」的可取消等待(原阻塞式 with 会让
  已取消任务仍要等全局名额腾出,卡死并拖后腿退款);aria2p 的同步 RPC 仍经
  to_thread,轮询改 asyncio.sleep;新增 core.http_client.iter_chunks,把**流式
  读取期**的 aiohttp.ClientError 收敛成 UpstreamNetworkError(请求建立阶段早已
  有这条路径,读取阶段此前没有,断流会绕过调用方的 UpstreamError 分类)。
  删除 get_http_session / _SESSION_LOCAL。
  models._RetryFromScratch 不再继承 requests.RequestException 而继承 Exception:
  它是「续传起点失效、残片已清、从头重下」,既不是确定性失败
  (DownloadException),也不是残片已保留的网络失败(UpstreamError),必须独立
  成类且在 except 链中先于二者捕获。
- music_api:podcast.py 全部函数协程化(直链解析内部改
  await NeteaseAPI.get_song_url);compat.py 删除——16 个死掉的同步门面早已
  清空,文件只剩空壳。
- services:_taskcore / _zipcore / batch / downloads / podcast / tasks 协程化。
  threading.Thread → loop.create_task(任务句柄字段 worker_thread →
  worker_task),ThreadPoolExecutor → asyncio.gather + asyncio.Semaphore,
  终态函数与结算回调全部改 await;监控线程里「worker 已死」判定改 task.done()。
  删除 services/streaming.py(同步流垫片,唯一消费者已迁到
  core.http_client.open)。zipfile 实例仍非线程安全,故「下载」并行、
  「入包」严格串行。
- web:路由层 130+ 处 to_thread 拆除——被包住的目标自己已是协程,再包一层只是
  把协程丢进线程里跑一圈。tasks_registry.start_task 直接 create_task。
  web/app.py 关闭段去掉 async_bridge.shutdown()(不再有第二条约循环)。
  web/routers/download.py 的播客端点补上 3 处漏掉的 await
  (extract_podcast_ref / get_radio_detail)——漏掉时不报错、只静默返回协程对象,
  靠 pyright 抓出来后修正。

留在 to_thread 上的都是**正当的阻塞卸载**:同步 SQLite 落库与账本结算、
mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、aria2p 的同步 RPC、
PBKDF2 的 10 万次迭代。ruff 对残留的本地元数据操作(exists/stat/os.replace/
往 .part 顺序写分块)按「微秒级、卸载的调度开销大于操作本身」放行,pyproject
逐条注明理由。

取消/暂停仍由 threading.Event 承载:置位方是请求线程/监控线程,读取方是 worker
协程,只有「置位」与「轮询读取」两种操作,既不需要跨循环唤醒也不会卡住事件
循环——比 asyncio.Event 更省心(ASYNC110 同理按此豁免)。

测试:10 个文件的替身边界同步迁移——网络替身从 core.sync_http 的
get_http_session 换成 downloader/服务模块的 _http.open + **真实** iter_chunks
(顺带钉住读取期异常收敛);`_run` 改为在独立线程里 asyncio.run(worker(...));
aria2 的假时钟从 time.sleep 换成 _POLL_INTERVAL=0;monitor 的线程句柄替身换成
done() 可控的轻量替身(asyncio.Task 的构造需要运行中的事件循环,而监控循环跑在
常驻线程里);podcast / 封面 / 结算包装等替身一律改 async。

门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败)。

下一步 B2:auth_system 12 个模块改 aiosqlite 背板、web 层剩余 to_thread 收尾;
B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py(现仅
sync_http 自用)。
2026-09-28 19:36:56 +08:00

284 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""aria2c 常驻 RPC 下载后端(可选加速通道;不可用时调用方降级)。
为什么用常驻 RPC 而不是每个任务起一个 aria2c 进程:
- 批量任务最多 500 首,逐首歌都付一次进程启动 + RPC 握手开销不划算;
- 取消/查询只需一次 RPC,不必在进程表里追踪 N 个子进程。
安全与稳健:
- RPC 只监听 127.0.0.1 并强制随机 secret —— 无鉴权的 aria2 RPC 允许本机任意
进程操纵下载队列(读写任意文件),暴露到容器外等于开了一个后门;
- aria2c 缺失 / 进程早退 / RPC 未就绪,一律返回 None 静默降级,
由调用方回落到内置的 core.http_client 流式通道;
- aria2 没有「单文件体积上限」选项,故用轮询 tellStatus 实现体积守卫与取消响应。
异步口径:aria2p 客户端本身是同步阻塞实现(内部走 urllib),故每次 RPC 调用
(提交/查询/移除)都经 ``asyncio.to_thread`` 卸载;轮询间隔改 ``asyncio.sleep``
让出事件循环。进程启动(``_try_start``)最长阻塞 8s,调用方同样经 to_thread 触发。
"""
from __future__ import annotations
import asyncio
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.http_client import UpstreamNetworkError
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)
# ---------- 下载 ----------
async 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 报告任务失败、总超时
UpstreamNetworkError: 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 = await asyncio.to_thread(self._api.add_uris, [url], options=options)
except Exception as e:
raise UpstreamNetworkError(f'aria2 提交任务失败: {e}') from e
deadline = time.monotonic() + timeout
while True:
if cancel_event is not None and cancel_event.is_set():
await asyncio.to_thread(self._abort, download, drop_files=True)
raise DownloadCanceled('下载已取消')
try:
await asyncio.to_thread(download.update)
except Exception as e:
# RPC 抖动按可重试的网络失败上报,交给外层退避重试
raise UpstreamNetworkError(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:
await asyncio.to_thread(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 UpstreamNetworkError(
f'aria2 下载失败: {download.error_message or download.status}')
if time.monotonic() > deadline:
await asyncio.to_thread(self._abort, download, drop_files=False)
raise UpstreamNetworkError('aria2 下载超时')
await asyncio.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(调用方降级)。
首次调用尝试拉起常驻进程(最长阻塞 _RPC_READY_TIMEOUT 秒),之后命中缓存;
失败结果进程内记忆,不反复重试。**同步阻塞**——异步调用方须经
``asyncio.to_thread(get_backend)`` 触发,避免首次启动期间冻住事件循环。
"""
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