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/services/downloads.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

334 lines
13 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 -*-
"""单曲下载任务 worker:下载 → 打包 → 交付 → 结算。
账号探测(VIP/音质)与二维码登录客户端均已迁出(前者只剩 web.routers.account
的异步实现,后者只剩 web.routers.admin 自己的客户端),本模块只负责单曲任务。
``_single_download_worker`` 原是 177 行 / 圈复杂度 30 的单函数,三个 except 分支
各写一遍「结算 + 删任务目录」,正文里又散着四处取消检查。此处按「一步一个具名
函数」拆开:主函数只留线性流程,失败收尾收敛到 ``_fail_single_task`` 一处。
worker 自本轮起是协程:下载链路本身已异步,落库/删目录等阻塞调用经
``asyncio.to_thread`` 卸载(auth_system 仍是同步 SQLite,DB 线异步化后即可
直接 await)。
"""
import asyncio
import os
import shutil
import threading
import time
import traceback
import zipfile
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import auth_system
from core.state import (
DOWNLOAD_TASKS,
DOWNLOAD_TASKS_LOCK,
)
from core.utils import format_lyric as _format_lyric
from music_downloader import (
DownloadCanceled,
DownloadException,
MusicInfo,
fetch_cover_bytes,
)
from ._shared import (
_build_download_base,
_record_log,
)
from ._taskcore import (
_mark_task_terminal,
)
from .api_service import (
api_service,
)
def _task_delivery_copy(task_id: str, src: Path, filename: str) -> str:
"""把共享缓存文件复制/硬链接为任务专属交付文件(任务成果与共享缓存分离,
任何任务的清理都不会再删掉别的任务的成果)"""
task_dir = api_service.downloads_path / 'tasks' / task_id
task_dir.mkdir(parents=True, exist_ok=True)
dst = task_dir / filename
if dst.exists():
return str(dst)
try:
os.link(str(src), str(dst)) # 同卷硬链接:零拷贝
except OSError:
shutil.copy2(str(src), str(dst))
return str(dst)
# _cleanup_task_dir 死代码删除——worker 直接 rmtree、监控线程直接
# rmtree,本函数全文件零调用(审计确认)。
@dataclass(frozen=True)
class SingleTaskParams:
"""单曲任务参数快照(worker 只读)。
原来是 10 个局部变量从 ``task['params']`` 逐个取出、再原样穿过整段流程;
收成一个 frozen 对象后,各步骤按需取字段,调用点不必重列一遍长参数表。
``cancel_ev`` 不是 params 里的值,而是任务对象上的取消事件,一并放这里
以免每一步都多传一个参数。
``music_id`` 刻意保持 **原样透传**(不转换):它来自前端、经 ``_extract_id``
归一化成数字字符串,而下游 ``get_music_info`` / ``download_music_file`` 的
签名声明 ``int``——两侧一直不一致,运行时靠字符串参与上游查询拼装而正常工作
(调用链上无算术)。类型标注为 ``Any`` 如实反映这个输入边界的实际形状;
顺手 ``int()`` 会改变异常面(非数字时从「上游查不到」变成 ``ValueError``),
属于行为变更,不在重构范围内。
"""
music_id: Any
quality: str
filename_format: str
write_meta: bool
meta_fields: Any
lyric_format: str
need_zip: bool
cookies: dict[str, str]
remote_ip: str
cancel_ev: threading.Event
def _begin_single_task(task_id: str) -> SingleTaskParams:
"""把任务置为 running 并取出参数快照。"""
with DOWNLOAD_TASKS_LOCK:
task = DOWNLOAD_TASKS[task_id]
task['status'] = 'running'
task['msg'] = '下载中...'
params = task['params']
return SingleTaskParams(
music_id=params['music_id'],
quality=params['quality'],
filename_format=params['filename_format'],
write_meta=params['write_meta'],
meta_fields=params['meta_fields'],
lyric_format=params['lyric_format'],
need_zip=params['need_zip'],
cookies=params['cookies'],
remote_ip=params.get('remote_ip') or '-',
cancel_ev=task['cancel'],
)
async def _fetch_single_metadata(p: SingleTaskParams) -> tuple[MusicInfo, dict[str, Any], str, str]:
"""一次预取歌曲完整信息(URL/详情/歌词三接口并发),并算出交付基础名。
替代原先 name_v1 + url_v1 + 下载器内部 get_music_info 的重复串行调用。
返回 ``(MusicInfo, 下载器口径的 music_info, base, filename)``。
"""
mi = await api_service.downloader.get_music_info(p.music_id, p.quality, p.cookies)
music_info = {
'id': p.music_id,
'name': mi.name,
'artist_string': mi.artists,
'album': mi.album,
'pic_url': mi.pic_url,
'file_type': mi.file_type,
'file_size': mi.file_size,
'duration': mi.duration * 1000,
'download_url': mi.download_url,
}
base = _build_download_base(p.filename_format, music_info)
return mi, music_info, base, f"{base}.{music_info['file_type']}"
def _single_progress_cb(task_id: str) -> Callable[[int, int], None]:
"""构造字节级进度回调(限频 0.4s):写进任务 live_percent。
/api/batch_progress 对运行中的单曲任务优先返回它——原实现 done 按首计数,
单曲进度条全程 0%。回调内任何故障都被吞掉,绝不影响下载主流程。
"""
last_ts = {'t': 0.0}
def _cb(written: int, total_bytes: int) -> None:
try:
now = time.time()
if now - last_ts['t'] < 0.4 and written < (total_bytes or 0):
return
last_ts['t'] = now
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if not t or t.get('status') != 'running':
return
if total_bytes:
pct = min(99.0, written / total_bytes * 100)
t['live_percent'] = pct
t['msg'] = (f"下载中 {int(pct)}% · "
f"{api_service._format_file_size(written)}/{api_service._format_file_size(total_bytes)}")
else:
t['msg'] = f"下载中 · 已接收 {api_service._format_file_size(written)}"
except Exception:
pass # 进度上报故障绝不影响下载
return _cb
def _abort_if_canceled(cancel_ev: threading.Event) -> None:
"""取消检查点:下载完成后到发布前,每一步之间都要过这道闸。
原先这三处检查(下载后 / 打包后 / 发布前)逐字重复,且末段那次还兼
「不得把已取消覆盖成 done」的职责——后者在 ``_publish_single_task`` 里另做。
"""
if cancel_ev.is_set():
raise DownloadCanceled("任务已取消")
def _single_file_result(file_path_str: str, filename: str,
music_info: dict[str, Any]) -> dict[str, Any]:
"""不打包(zip=false)的交付描述。"""
return {
'file_path': file_path_str,
'filename': filename,
'mimetype': f"audio/{music_info['file_type']}",
'audio_files': [file_path_str],
}
async def _pack_single_zip(file_path_str: str, base: str, filename: str, mi: MusicInfo,
music_info: dict[str, Any], p: SingleTaskParams) -> dict[str, Any]:
"""把音频 + 歌词 + 封面 + info.txt 打成单曲 ZIP 交付包。
歌词与封面都是**尽力而为**:歌词在预取时已一并拿到(无需再请求一次),
封面走共享缓存;两者拿不到就不写对应条目,不影响音频交付。
ZIP 组装是整段文件拷贝的阻塞 IO,经 to_thread 卸载。
"""
import tempfile as _tempfile
# 封面走共享缓存(与元数据写入共用同一次网络请求)——先取好再落包,
# 网络等待留在事件循环里(协程),文件写入整段交给线程
cover: bytes = b''
if mi.pic_url:
cover = await fetch_cover_bytes(mi.pic_url) or b''
fd, zip_tmp_path = _tempfile.mkstemp(prefix='single_zip_', suffix='.zip',
dir=str(Path(file_path_str).parent))
os.close(fd)
def _write_zip() -> None:
with zipfile.ZipFile(zip_tmp_path, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.write(file_path_str, arcname=filename)
# 歌词已在预取时一并拿到,无需再请求一次
if mi.lyric:
try:
zf.writestr(f"{base}.{p.lyric_format}",
_format_lyric(mi.lyric, p.lyric_format))
except Exception:
pass
if cover:
zf.writestr(f"{base}.jpg", cover)
info_txt = (
f"歌曲: {music_info['name']}\n"
f"歌手: {music_info['artist_string']}\n"
f"专辑: {music_info['album']}\n"
f"音质: {api_service._get_quality_display_name(p.quality)}\n"
)
zf.writestr("info.txt", info_txt)
await asyncio.to_thread(_write_zip)
return {
'file_path': file_path_str,
'zip_path': zip_tmp_path,
'filename': f"{base}.zip",
'mimetype': 'application/zip',
'audio_files': [file_path_str],
}
async def _publish_single_task(task_id: str, result: dict[str, Any], remote_ip: str,
name: str, filename: str) -> None:
"""最终发布:置 done(已终态不覆盖)→ 结算 → 记操作日志。
交付已落定;结算失败**不再**把任务改标 error——钱由重启对账兜底,
宁可商家小损不坑用户。末段取消检查与此处的状态检查合流:任务已被外部
置成 canceled/error 时,worker 的成功路径不得把它覆盖回 done。
"""
with DOWNLOAD_TASKS_LOCK:
t = DOWNLOAD_TASKS.get(task_id)
if t is None or t.get('status') in ('canceled', 'error'):
raise DownloadCanceled("任务已取消")
t['status'] = 'done'
t['completed_at'] = time.time()
t['done'] = 1
t['msg'] = '下载完成'
t['result'] = result
try:
await asyncio.to_thread(auth_system.ledger_settle, task_id, 1, 'delivered')
except Exception as _e:
api_service.logger.error(f"单曲交付结算失败(重启对账兜底): {task_id} {_e}")
try:
_record_log('下载', remote_ip, name)
except Exception:
pass
api_service.logger.info(f"单曲下载完成: {filename} ({task_id})")
async def _fail_single_task(task_id: str, status: str, msg: str, ledger_status: str,
task_dir_path: str | None) -> None:
"""失败/取消的统一收尾:置终态 → 退款 → 清掉任务目录。
这三个 except 分支原先各写一遍(共 9 行 × 3 处);收敛后新增一种失败
只需要挑一个 ``ledger_status``,不会再漏掉清理或退款。
"""
await _mark_task_terminal(task_id, status, msg)
try:
await asyncio.to_thread(auth_system.ledger_settle, task_id, 0, ledger_status)
except Exception:
pass
if task_dir_path:
await asyncio.to_thread(shutil.rmtree, task_dir_path, ignore_errors=True)
async def _single_download_worker(task_id: str) -> None:
"""后台单曲下载:下载音频(可选 ZIP 打包),支持取消;完成后由 /download/result 交付,35 分钟保留期清理"""
task_dir_path: str | None = None
try:
p = _begin_single_task(task_id)
mi, music_info, base, filename = await _fetch_single_metadata(p)
download_result = await api_service.downloader.download_music_file(
p.music_id, p.quality,
write_meta=p.write_meta,
meta_fields=p.meta_fields,
cookies=p.cookies,
cancel_event=p.cancel_ev,
music_info=mi,
progress_cb=_single_progress_cb(task_id),
)
if not download_result.success:
raise DownloadException(download_result.error_message or "下载失败")
_abort_if_canceled(p.cancel_ev)
assert download_result.file_path is not None
# 交付文件用任务专属副本,共享缓存不进清理清单
file_path_str = await asyncio.to_thread(
_task_delivery_copy, task_id, Path(download_result.file_path), filename)
task_dir_path = str(Path(file_path_str).parent)
if p.need_zip:
result = await _pack_single_zip(file_path_str, base, filename, mi, music_info, p)
else:
result = _single_file_result(file_path_str, filename, music_info)
# 打包阶段同样以取消检查收口
_abort_if_canceled(p.cancel_ev)
await _publish_single_task(task_id, result, p.remote_ip, music_info['name'], filename)
except DownloadCanceled:
api_service.logger.info(f"单曲下载已取消: {task_id}")
await _fail_single_task(task_id, 'canceled', '任务已取消', 'canceled', task_dir_path)
except DownloadException as e:
api_service.logger.error(f"单曲下载失败: {task_id} {e}")
await _fail_single_task(task_id, 'error', str(e), 'error', task_dir_path)
except Exception as e:
# 非预期异常的 repr 可能含上游主机/连接细节——用户可见文案
# 泛化为固定文案,原文 logger 先行留档(教训:先记录再泛化)
api_service.logger.error(f"单曲下载异常: {task_id} {e}\n{traceback.format_exc()}")
await _fail_single_task(task_id, 'error', '下载失败,请稍后重试', 'error', task_dir_path)