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/tests/test_downloader.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

619 lines
21 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 -*-
"""MusicDownloader.download_music_file 的行为契约测试(沙箱,不触碰生产 data/)。
为什么:download_music_file 是**唯一**的音频落盘路径——单曲 worker 与音乐批量
ZIP 都经它,约 205 行 / 圈复杂度 47,此前**零直接测试覆盖**(test_zip_workers 里
只有一个 stub)。它同时承担缓存校验、断点续传、内容嗅探、体积上限、取消语义、
重试退避与元数据写入,任何一条被拆坏都不会有测试报警。
断言只依赖「调用后世界里发生了什么」——返回值、磁盘上的文件、发往网络的请求——
不引用任何内部函数名,因此本文件可作为后续拆分的等价性验证器。
替身约定(均为模块级符号,替换点在 downloader 模块命名空间):
- 网络:``_http``(core.http_client 的进程级异步客户端)——替身提供
``async open(method, url, **kw)``,返回带 ``status``/``headers``/``content``
的未关闭响应;分块读取走**真实的** ``iter_chunks``,顺带钉住「读取期异常
收敛为 UpstreamNetworkError」这条边界。
- aria2:``get_backend``(返回 None = 强制走内置 HTTP 通道)
- 内容嗅探:``_sniff_audio_header``(默认放行,按需置 False)
- 元数据写入:``MusicDownloader._write_metadata``(不真跑 mutagen)
"""
from __future__ import annotations
import threading
from pathlib import Path
from typing import Any
import aiohttp
import pytest
from music_downloader import DownloadCanceled, DownloadException, MusicInfo
from music_downloader.downloader import MusicDownloader
# ============ 替身 ============
class _FakeContent:
"""``aiohttp.ClientResponse.content`` 的替身。
chunks 保留原始 iterable(**不要** list() 归一化):生成器的副作用
(如中途置取消位)若在构造时刻就求值,「中途取消」会被悄悄测成
「开始前取消」——两条不同的路径混成一条,而测试仍是绿的。
元素约定:可调用对象 → 调用一次(注入副作用,如 cancel.set);
异常实例 → 抛出(模拟读取期断流);其余 → 作为分块产出。
"""
def __init__(self, chunks):
self._chunks = chunks
async def iter_chunked(self, chunk_size=0):
for item in self._chunks:
if callable(item):
item()
continue
if isinstance(item, BaseException):
raise item
yield item
class _FakeResponse:
"""``core.http_client.open`` 返回的未关闭流式响应替身。"""
def __init__(self, chunks, status=200, headers=None, reason=''):
self.status = status
self.reason = reason
self.headers = dict(headers or {})
self.content = _FakeContent(chunks)
self.closed = False
def close(self):
self.closed = True
class _FakeClient:
"""记录每次 open(含 kw)并按序产出响应;responder 可随用例重装。"""
def __init__(self):
self._responder = None
self.calls: list[tuple[str, dict[str, Any]]] = []
def set_responder(self, responder):
self._responder = responder
async def open(self, method, url, **kw):
self.calls.append((url, kw))
assert self._responder is not None, '本用例未安装网络替身'
return self._responder(len(self.calls), url, kw)
def _body(size, status=200, content_range=None):
"""固定返回 size 字节的响应工厂(每次调用产出新响应对象)。"""
def _mk(_attempt, _url, _kw):
headers = {'Content-Range': content_range} if content_range else {}
return _FakeResponse([b'x' * size], status=status, headers=headers)
return _mk
def _headers(kw) -> dict[str, str]:
"""取一次调用的请求头——没带 Range 时 downloader 传的是 None。"""
return kw.get('headers') or {}
def _info(mid=101, size=20, file_type='mp3'):
return MusicInfo(
id=mid, name='测试曲', artists='歌手', album='专辑', pic_url='',
duration=180, track_number=1, download_url='http://cdn.test/a.mp3',
file_type=file_type, file_size=size, quality='standard',
)
def _paths(dl, info):
"""按生产同一函数推算落盘路径与 .part 路径(纯函数,可直接复用)。"""
fp = dl._build_file_path(info)
return fp, fp.with_name(fp.name + '.part')
@pytest.fixture()
def env(tmp_path, monkeypatch):
"""返回 (downloader, state, install)。
- state['sniff']:内容嗅探结果,默认 True
- state['meta']:_write_metadata 的调用记录 [(文件路径, fields), ...]
- install(responder) -> _FakeClient:安装网络替身,返回它以便断言
"""
state: dict[str, Any] = {'sniff': True, 'meta': []}
client = _FakeClient()
def install(responder):
client.set_responder(responder)
return client
async def _record_meta(self, file_path, music_info, fields=None):
state['meta'].append((str(file_path), fields))
return True
monkeypatch.setattr('music_downloader.downloader._http', client)
monkeypatch.setattr('music_downloader.downloader.get_backend', lambda: None)
monkeypatch.setattr('music_downloader.downloader._sniff_audio_header',
lambda _p: state['sniff'])
monkeypatch.setattr(MusicDownloader, '_write_metadata', _record_meta)
install(lambda *a: pytest.fail('本用例不应发出网络请求'))
dl = MusicDownloader(download_dir=str(tmp_path))
return dl, state, install
# ============ 正常落盘 ============
async def test_success_promotes_part_to_final(env):
dl, _state, install = env
info = _info(size=20)
fp, part = _paths(dl, info)
install(_body(20))
r = await dl.download_music_file(101, music_info=info)
assert r.success and r.error_message == ''
assert Path(r.file_path or '') == fp
assert r.file_size == 20 and fp.read_bytes() == b'x' * 20
assert not part.exists(), '完成后 .part 应改名转正'
async def test_empty_chunks_skipped(env):
dl, _state, install = env
info = _info(size=20)
fp, _ = _paths(dl, info)
install(lambda a, u, kw: _FakeResponse([b'', b'x' * 20, b'']))
seen: list[int] = []
r = await dl.download_music_file(101, music_info=info,
progress_cb=lambda w, _t: seen.append(w))
assert r.success and fp.stat().st_size == 20
assert seen == [0, 20], '空分块不写盘也不触发进度回调'
async def test_progress_reports_resume_start_then_each_chunk(env):
dl, _state, install = env
info = _info(size=20)
install(lambda a, u, kw: _FakeResponse([b'a' * 8, b'b' * 12]))
seen: list[tuple[int, int]] = []
r = await dl.download_music_file(101, music_info=info,
progress_cb=lambda w, t: seen.append((w, t)))
assert r.success
assert seen == [(0, 20), (8, 20), (20, 20)], '起始回调带续传起点,此后每块一次'
async def test_progress_callback_exception_never_breaks_download(env):
dl, _state, install = env
info = _info(size=20)
install(_body(20))
def _boom(_w, _t):
raise RuntimeError('回调炸了')
r = await dl.download_music_file(101, music_info=info, progress_cb=_boom)
assert r.success, '进度回调异常绝不影响下载主流程'
async def test_music_info_fetched_when_not_provided(env, monkeypatch):
dl, _state, install = env
info = _info(size=20)
seen: list[tuple] = []
async def _fake_info(mid, q, c=None):
seen.append((mid, q))
return info
monkeypatch.setattr(dl, 'get_music_info', _fake_info)
install(_body(20))
r = await dl.download_music_file(101, 'lossless')
assert r.success and seen == [(101, 'lossless')]
# ============ 缓存校验 ============
async def test_cache_hit_skips_network(env):
dl, _state, install = env
info = _info(size=20)
fp, _ = _paths(dl, info)
fp.write_bytes(b'c' * 20)
session = install(lambda *a: pytest.fail('缓存命中不应发网络请求'))
r = await dl.download_music_file(101, music_info=info)
assert r.success and r.file_size == 20
assert session.calls == []
async def test_cache_size_mismatch_triggers_redownload(env):
dl, _state, install = env
info = _info(size=20)
fp, _ = _paths(dl, info)
fp.write_bytes(b'c' * 15) # 与本次期望不符 = 缓存 miss
session = install(_body(20))
r = await dl.download_music_file(101, music_info=info)
assert r.success and fp.read_bytes() == b'x' * 20
assert len(session.calls) == 1, '坏缓存应被删除并重下,而不是当结果交付'
async def test_zero_byte_cache_rejected_when_expected_size_unknown(env):
dl, _state, install = env
info = _info(size=0)
fp, _ = _paths(dl, info)
fp.write_bytes(b'')
session = install(lambda a, u, kw: _FakeResponse([b'good']))
r = await dl.download_music_file(101, music_info=info)
assert r.success and fp.read_bytes() == b'good'
assert len(session.calls) == 1, '期望未知时至少拒绝 0 字节坏文件'
# ============ 断点续传 ============
async def test_resume_sends_range_and_appends(env):
dl, _state, install = env
info = _info(size=20)
fp, part = _paths(dl, info)
part.write_bytes(b'p' * 10)
session = install(lambda a, u, kw: _FakeResponse(
[b'q' * 10], status=206, headers={'Content-Range': 'bytes 10-19/20'}))
seen: list[tuple[int, int]] = []
r = await dl.download_music_file(101, music_info=info,
progress_cb=lambda w, t: seen.append((w, t)))
assert r.success
assert _headers(session.calls[0][1]).get('Range') == 'bytes=10-'
assert fp.read_bytes() == b'p' * 10 + b'q' * 10, '续传应追加而非覆盖'
assert seen[0] == (10, 20), '起始进度应带续传起点'
async def test_server_ignoring_range_rewrites_from_zero(env):
dl, _state, install = env
info = _info(size=20)
fp, part = _paths(dl, info)
part.write_bytes(b'p' * 10)
install(lambda a, u, kw: _FakeResponse([b'r' * 20], status=200))
r = await dl.download_music_file(101, music_info=info)
assert r.success and fp.read_bytes() == b'r' * 20, '服务器返回 200 须从头重写'
async def test_complete_part_promoted_without_network(env):
dl, _state, install = env
info = _info(size=20)
fp, part = _paths(dl, info)
part.write_bytes(b'z' * 20) # 已下完但未改名
session = install(lambda *a: pytest.fail('完整残片不应发网络请求'))
r = await dl.download_music_file(101, music_info=info)
assert r.success and r.file_size == 20
assert session.calls == []
assert fp.read_bytes() == b'z' * 20 and not part.exists()
async def test_oversized_part_discarded_starts_from_zero(env):
dl, _state, install = env
info = _info(size=20)
_fp, part = _paths(dl, info)
part.write_bytes(b'o' * 30) # 超尺寸残片没有续传价值
session = install(_body(20))
r = await dl.download_music_file(101, music_info=info)
assert r.success
assert 'Range' not in _headers(session.calls[0][1])
async def test_zero_byte_part_discarded(env):
dl, _state, install = env
info = _info(size=20)
_fp, part = _paths(dl, info)
part.write_bytes(b'')
session = install(_body(20))
r = await dl.download_music_file(101, music_info=info)
assert r.success and 'Range' not in _headers(session.calls[0][1])
async def test_content_range_offset_mismatch_restarts_from_scratch(env):
dl, _state, install = env
info = _info(size=20)
fp, part = _paths(dl, info)
part.write_bytes(b'p' * 10)
def responder(n, _url, _kw):
if n == 1:
return _FakeResponse([b'q' * 5], status=206,
headers={'Content-Range': 'bytes 3-7/20'})
return _FakeResponse([b'f' * 20], status=200)
session = install(responder)
r = await dl.download_music_file(101, music_info=info, retries=1)
assert r.success and len(session.calls) == 2
assert 'Range' not in _headers(session.calls[1][1]), '清残片后应从头下'
assert fp.read_bytes() == b'f' * 20
async def test_206_without_content_range_restarts_from_scratch(env):
dl, _state, install = env
info = _info(size=20)
_fp, part = _paths(dl, info)
part.write_bytes(b'p' * 10)
def responder(n, _url, _kw):
if n == 1:
return _FakeResponse([b'q' * 5], status=206) # 头被中间层剥离
return _FakeResponse([b'f' * 20], status=200)
session = install(responder)
r = await dl.download_music_file(101, music_info=info, retries=1)
assert r.success and len(session.calls) == 2
assert 'Range' not in _headers(session.calls[1][1])
async def test_http_416_restarts_from_scratch(env):
dl, _state, install = env
info = _info(size=20)
fp, part = _paths(dl, info)
part.write_bytes(b'p' * 10)
def responder(n, _url, _kw):
if n == 1:
return _FakeResponse([], status=416)
return _FakeResponse([b'f' * 20], status=200)
session = install(responder)
r = await dl.download_music_file(101, music_info=info, retries=1)
assert r.success and len(session.calls) == 2
assert 'Range' not in _headers(session.calls[1][1])
assert fp.read_bytes() == b'f' * 20
# ============ 失败与重试 ============
async def test_cdn_4xx_is_deterministic_and_not_retried(env):
dl, _state, install = env
info = _info(size=20)
session = install(lambda a, u, kw: _FakeResponse([], status=403))
with pytest.raises(DownloadException) as ei:
await dl.download_music_file(101, music_info=info, retries=2)
assert 'HTTP 403' in str(ei.value)
assert len(session.calls) == 1, '确定性失败对同一失效直链白重试毫无意义'
async def test_408_is_retried_then_reported_as_failure(env):
dl, _state, install = env
info = _info(size=20)
session = install(lambda a, u, kw: _FakeResponse([], status=408))
r = await dl.download_music_file(101, music_info=info, retries=1)
assert not r.success
assert '下载请求失败' in r.error_message
assert len(session.calls) == 2, '408 请求超时应保留可重试'
async def test_5xx_is_retried_and_keeps_part(env):
"""5xx 是服务端瞬时故障(非确定性失败):应可重试,且保留 .part 供续传。
与 4xx 的分界在这里钉住:error 分类只看 400<=status<500,5xx 落回裸重抛,
由外层 UpstreamError 分支退避重试。此前这条路径无测试覆盖。
"""
dl, _state, install = env
info = _info(size=100)
fp, part = _paths(dl, info)
part.write_bytes(b'p' * 10)
session = install(lambda a, u, kw: _FakeResponse([], status=500))
r = await dl.download_music_file(101, music_info=info, retries=1)
assert not r.success and '下载请求失败' in r.error_message
assert len(session.calls) == 2, '5xx 非确定性失败,应重试'
assert not fp.exists(), '未交付不得留下最终文件'
assert part.stat().st_size == 10, '.part 应保留供下轮续传'
async def test_incomplete_size_keeps_part_and_reports(env):
dl, _state, install = env
info = _info(size=100)
fp, part = _paths(dl, info)
install(_body(10))
r = await dl.download_music_file(101, music_info=info, retries=0)
assert not r.success and '下载不完整' in r.error_message
assert part.exists() and part.stat().st_size == 10, '.part 应保留以供续传'
assert not fp.exists(), '不完整的下载不得转正交付'
async def test_incomplete_then_resumes_on_next_attempt(env):
dl, _state, install = env
info = _info(size=100)
_fp, part = _paths(dl, info)
def responder(n, _url, _kw):
if n == 1:
return _FakeResponse([b'a' * 10], status=200)
return _FakeResponse([b'b' * 5], status=206,
headers={'Content-Range': 'bytes 10-14/100'})
session = install(responder)
r = await dl.download_music_file(101, music_info=info, retries=1)
assert not r.success and '下载不完整' in r.error_message
assert _headers(session.calls[1][1]).get('Range') == 'bytes=10-', '下轮应续传'
assert part.stat().st_size == 15
async def test_network_error_keeps_part_for_resume(env):
"""读取期断流:真实的 iter_chunks 把 aiohttp.ClientError 收敛成
UpstreamNetworkError,外层据此保留 .part 供续传。"""
dl, _state, install = env
info = _info(size=100)
fp, part = _paths(dl, info)
install(lambda a, u, kw: _FakeResponse(
[b'a' * 10, aiohttp.ClientPayloadError('连接中断')]))
r = await dl.download_music_file(101, music_info=info, retries=0)
assert not r.success and '下载请求失败' in r.error_message
assert part.exists() and part.stat().st_size == 10, '网络错误应保留 .part'
assert not fp.exists()
# ============ 内容校验与体积上限 ============
async def test_unknown_size_non_audio_content_rejected(env):
dl, state, install = env
info = _info(size=0)
fp, part = _paths(dl, info)
state['sniff'] = False
install(lambda a, u, kw: _FakeResponse([b'<html>blocked</html>']))
with pytest.raises(DownloadException) as ei:
await dl.download_music_file(101, music_info=info)
assert '非音频数据' in str(ei.value)
assert not part.exists() and not fp.exists()
async def test_unknown_size_audio_content_accepted(env):
dl, state, install = env
info = _info(size=0)
fp, _ = _paths(dl, info)
state['sniff'] = True
install(lambda a, u, kw: _FakeResponse([b'ID3audio']))
r = await dl.download_music_file(101, music_info=info)
assert r.success and fp.read_bytes() == b'ID3audio'
async def test_size_limit_discards_part_and_fails(env, monkeypatch):
dl, _state, install = env
monkeypatch.setattr('music_downloader.downloader._MAX_DOWNLOAD_BYTES', 16)
info = _info(size=0)
fp, part = _paths(dl, info)
install(lambda a, u, kw: _FakeResponse([b'x' * 100]))
with pytest.raises(DownloadException) as ei:
await dl.download_music_file(101, music_info=info)
assert '大小上限' in str(ei.value)
assert not part.exists() and not fp.exists(), '超限残片无续传价值,须清理'
# ============ 取消 ============
async def test_cancel_before_transfer_clears_part_and_skips_network(env):
dl, _state, install = env
info = _info(size=20)
_fp, part = _paths(dl, info)
part.write_bytes(b'p' * 5)
cancel = threading.Event()
cancel.set()
session = install(lambda *a: pytest.fail('已取消不应发网络请求'))
with pytest.raises(DownloadCanceled):
await dl.download_music_file(101, music_info=info, cancel_event=cancel)
assert session.calls == []
assert not part.exists(), '取消须清掉 .part'
async def test_cancel_midway_clears_half_written_file(env):
dl, _state, install = env
info = _info(size=20)
fp, part = _paths(dl, info)
cancel = threading.Event()
# 第二块产出**之前**置取消位:取消发生在迭代过程中(.part 已建立)
install(lambda a, u, kw: _FakeResponse([b'a' * 5, cancel.set, b'b' * 15]))
with pytest.raises(DownloadCanceled):
await dl.download_music_file(101, music_info=info, cancel_event=cancel)
assert not part.exists() and not fp.exists()
async def test_network_error_while_canceled_reports_canceled(env):
dl, _state, install = env
info = _info(size=20)
fp, part = _paths(dl, info)
cancel = threading.Event()
install(lambda a, u, kw: _FakeResponse([b'a' * 5, cancel.set, OSError('句柄异常')]))
with pytest.raises(DownloadCanceled):
await dl.download_music_file(101, music_info=info, cancel_event=cancel, retries=2)
assert not part.exists() and not fp.exists(), '已取消时残片没有续传价值'
async def test_cancel_after_promote_keeps_file_but_skips_metadata(env):
dl, state, install = env
info = _info(size=20)
fp, part = _paths(dl, info)
cancel = threading.Event()
def cb(written, _total):
if written >= 20:
cancel.set()
install(_body(20))
with pytest.raises(DownloadCanceled):
await dl.download_music_file(101, music_info=info, cancel_event=cancel,
progress_cb=cb)
assert fp.exists() and not part.exists(), '已转正的文件不回滚'
assert state['meta'] == [], '取消后不应再写元数据'
# ============ 元数据 ============
async def test_metadata_written_only_when_requested(env):
dl, state, install = env
install(_body(20))
await dl.download_music_file(101, music_info=_info(mid=101), write_meta=False)
assert state['meta'] == [], 'write_meta=False 不应写元数据'
r = await dl.download_music_file(102, music_info=_info(mid=102), write_meta=True,
meta_fields=['title'])
assert r.success
assert [f for _p, f in state['meta']] == [['title']], 'fields 应原样透传'