Archived
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 自用)。
This commit is contained in:
1 parent
65adfc9089
commit
69a50c2c31
35 files changed
+1062
-1005
No files matched your search
+24
-23
@@ -14,7 +14,7 @@ import threading
|
||||
import pytest
|
||||
|
||||
import music_downloader._aria2 as a2
|
||||
from core.sync_http import RequestException
|
||||
from core.http_client import UpstreamNetworkError
|
||||
from music_downloader import DownloadCanceled, DownloadException
|
||||
|
||||
|
||||
@@ -82,7 +82,8 @@ class _FakeProc:
|
||||
|
||||
|
||||
def _backend(monkeypatch, steps, **kw):
|
||||
monkeypatch.setattr(a2.time, 'sleep', lambda _s: None)
|
||||
# fetch 的轮询改 asyncio.sleep(不再 time.sleep):把间隔压到 0 让状态机快速走完
|
||||
monkeypatch.setattr(a2, '_POLL_INTERVAL', 0.0)
|
||||
dl = _FailUpdateDownload([]) if kw.get('fail_update') else _FakeDownload(steps)
|
||||
api = _FakeAPI(dl, fail_submit=kw.get('fail_submit', False))
|
||||
return a2.Aria2Backend(api, _FakeProc(), 6800), api
|
||||
@@ -104,11 +105,11 @@ def test_free_port_is_in_valid_range():
|
||||
|
||||
# ---------- 完成路径 ----------
|
||||
|
||||
def test_fetch_reports_progress_and_completes(monkeypatch, tmp_path):
|
||||
async def test_fetch_reports_progress_and_completes(monkeypatch, tmp_path):
|
||||
be, api = _backend(monkeypatch, [(50, False, False), (100, True, False)])
|
||||
seen = []
|
||||
be.fetch('http://x/y.mp3', tmp_path / 'y.part',
|
||||
progress_cb=lambda done, total: seen.append((done, total)))
|
||||
await be.fetch('http://x/y.mp3', tmp_path / 'y.part',
|
||||
progress_cb=lambda done, total: seen.append((done, total)))
|
||||
assert seen == [(50, 100), (100, 100)]
|
||||
assert api.removed == [] # 完成不需要中止
|
||||
assert not (tmp_path / 'y.part.aria2').exists() # 控制文件已兜底清理
|
||||
@@ -119,57 +120,57 @@ def test_fetch_reports_progress_and_completes(monkeypatch, tmp_path):
|
||||
assert options['max-connection-per-server'] == str(a2._SPLIT)
|
||||
|
||||
|
||||
def test_progress_callback_exception_is_swallowed(monkeypatch, tmp_path):
|
||||
async def test_progress_callback_exception_is_swallowed(monkeypatch, tmp_path):
|
||||
be, _api = _backend(monkeypatch, [(100, True, False)])
|
||||
|
||||
def boom(_d, _t):
|
||||
raise RuntimeError('cb')
|
||||
|
||||
be.fetch('http://x/y.mp3', tmp_path / 'y.part', progress_cb=boom) # 不应抛出
|
||||
await be.fetch('http://x/y.mp3', tmp_path / 'y.part', progress_cb=boom) # 不应抛出
|
||||
|
||||
|
||||
# ---------- 取消 / 守卫 / 失败 ----------
|
||||
|
||||
def test_fetch_maps_cancel_and_drops_partial(monkeypatch, tmp_path):
|
||||
async def test_fetch_maps_cancel_and_drops_partial(monkeypatch, tmp_path):
|
||||
be, api = _backend(monkeypatch, [(10, False, False)])
|
||||
ev = threading.Event()
|
||||
ev.set()
|
||||
with pytest.raises(DownloadCanceled):
|
||||
be.fetch('http://x/y.mp3', tmp_path / 'y.part', cancel_event=ev)
|
||||
await be.fetch('http://x/y.mp3', tmp_path / 'y.part', cancel_event=ev)
|
||||
assert api.removed == [('g1', True, True)]
|
||||
|
||||
|
||||
def test_fetch_guards_size_and_drops_partial(monkeypatch, tmp_path):
|
||||
async def test_fetch_guards_size_and_drops_partial(monkeypatch, tmp_path):
|
||||
be, api = _backend(monkeypatch, [(700, False, False)])
|
||||
with pytest.raises(DownloadException):
|
||||
be.fetch('http://x/y.mp3', tmp_path / 'y.part', max_bytes=500)
|
||||
await be.fetch('http://x/y.mp3', tmp_path / 'y.part', max_bytes=500)
|
||||
assert api.removed == [('g1', True, True)]
|
||||
|
||||
|
||||
def test_fetch_failure_is_retryable_and_keeps_partial(monkeypatch, tmp_path):
|
||||
async def test_fetch_failure_is_retryable_and_keeps_partial(monkeypatch, tmp_path):
|
||||
be, api = _backend(monkeypatch, [(10, False, True)])
|
||||
with pytest.raises(RequestException):
|
||||
be.fetch('http://x/y.mp3', tmp_path / 'y.part')
|
||||
with pytest.raises(UpstreamNetworkError):
|
||||
await be.fetch('http://x/y.mp3', tmp_path / 'y.part')
|
||||
assert api.removed == [] # 残片保留,供下轮续传
|
||||
|
||||
|
||||
def test_fetch_timeout_is_retryable(monkeypatch, tmp_path):
|
||||
async def test_fetch_timeout_is_retryable(monkeypatch, tmp_path):
|
||||
be, api = _backend(monkeypatch, [(10, False, False)]) # 永不完成
|
||||
with pytest.raises(RequestException):
|
||||
be.fetch('http://x/y.mp3', tmp_path / 'y.part', timeout=-1)
|
||||
with pytest.raises(UpstreamNetworkError):
|
||||
await be.fetch('http://x/y.mp3', tmp_path / 'y.part', timeout=-1)
|
||||
assert api.removed == [('g1', True, False)] # 超时中止但保留残片
|
||||
|
||||
|
||||
def test_fetch_submit_failure_is_retryable(monkeypatch, tmp_path):
|
||||
async def test_fetch_submit_failure_is_retryable(monkeypatch, tmp_path):
|
||||
be, _api = _backend(monkeypatch, [], fail_submit=True)
|
||||
with pytest.raises(RequestException):
|
||||
be.fetch('http://x/y.mp3', tmp_path / 'y.part')
|
||||
with pytest.raises(UpstreamNetworkError):
|
||||
await be.fetch('http://x/y.mp3', tmp_path / 'y.part')
|
||||
|
||||
|
||||
def test_fetch_update_failure_is_retryable(monkeypatch, tmp_path):
|
||||
async def test_fetch_update_failure_is_retryable(monkeypatch, tmp_path):
|
||||
be, _api = _backend(monkeypatch, [], fail_update=True)
|
||||
with pytest.raises(RequestException):
|
||||
be.fetch('http://x/y.mp3', tmp_path / 'y.part')
|
||||
with pytest.raises(UpstreamNetworkError):
|
||||
await be.fetch('http://x/y.mp3', tmp_path / 'y.part')
|
||||
|
||||
|
||||
def test_close_is_idempotent(monkeypatch):
|
||||
|
||||
@@ -51,12 +51,12 @@ def env(tmp_path, monkeypatch):
|
||||
state: dict = {'info_exc': None, 'dl_result': _dl(), 'dl_exc': None}
|
||||
|
||||
class _FakeDownloader:
|
||||
def get_music_info(self, music_id, quality, cookies):
|
||||
async def get_music_info(self, music_id, quality, cookies):
|
||||
if state['info_exc'] is not None:
|
||||
raise state['info_exc']
|
||||
return state['info']
|
||||
|
||||
def download_music_file(self, music_id, quality, **kw):
|
||||
async def download_music_file(self, music_id, quality, **kw):
|
||||
if state['dl_exc'] is not None:
|
||||
raise state['dl_exc']
|
||||
return state['dl_result']
|
||||
|
||||
+141
-124
@@ -9,64 +9,83 @@ ZIP 都经它,约 205 行 / 圈复杂度 47,此前**零直接测试覆盖**
|
||||
断言只依赖「调用后世界里发生了什么」——返回值、磁盘上的文件、发往网络的请求——
|
||||
不引用任何内部函数名,因此本文件可作为后续拆分的等价性验证器。
|
||||
|
||||
替身约定(三处,均为模块级符号,替换点在 downloader 模块命名空间):
|
||||
- 网络:``get_http_session`` / ``get_backend``(后者返回 None = 走内置 HTTP 通道)
|
||||
替身约定(均为模块级符号,替换点在 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 core import sync_http
|
||||
from music_downloader import DownloadCanceled, DownloadException, MusicInfo
|
||||
from music_downloader.downloader import MusicDownloader
|
||||
|
||||
# ============ 替身 ============
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""core.sync_http.SyncResponse 的替身。
|
||||
class _FakeContent:
|
||||
"""``aiohttp.ClientResponse.content`` 的替身。
|
||||
|
||||
chunks 保留原始 iterable(**不要** list() 归一化):生成器的副作用
|
||||
(如中途置取消位)若在构造时刻就求值,「中途取消」会被悄悄测成
|
||||
「开始前取消」——两条不同的路径混成一条,而测试仍是绿的。
|
||||
|
||||
元素约定:可调用对象 → 调用一次(注入副作用,如 cancel.set);
|
||||
异常实例 → 抛出(模拟读取期断流);其余 → 作为分块产出。
|
||||
"""
|
||||
|
||||
def __init__(self, chunks, status=200, headers=None):
|
||||
def __init__(self, chunks):
|
||||
self._chunks = chunks
|
||||
self.status_code = status
|
||||
|
||||
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 iter_content(self, chunk_size=0):
|
||||
yield from self._chunks
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise sync_http.HTTPError(f'HTTP {self.status_code}', self)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""记录每次 GET(含 headers)并按序产出响应。"""
|
||||
class _FakeClient:
|
||||
"""记录每次 open(含 kw)并按序产出响应;responder 可随用例重装。"""
|
||||
|
||||
def __init__(self, responder):
|
||||
self._responder = responder
|
||||
def __init__(self):
|
||||
self._responder = None
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
self.closed = False
|
||||
|
||||
def get(self, url, **kw):
|
||||
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 close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _body(size, status=200, content_range=None):
|
||||
"""固定返回 size 字节的响应工厂(每次调用产出新响应对象)。"""
|
||||
@@ -78,6 +97,11 @@ def _body(size, status=200, content_range=None):
|
||||
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='',
|
||||
@@ -98,21 +122,20 @@ def env(tmp_path, monkeypatch):
|
||||
|
||||
- state['sniff']:内容嗅探结果,默认 True
|
||||
- state['meta']:_write_metadata 的调用记录 [(文件路径, fields), ...]
|
||||
- install(responder) -> _FakeSession:安装网络替身,返回该替身以便断言
|
||||
- install(responder) -> _FakeClient:安装网络替身,返回它以便断言
|
||||
"""
|
||||
state: dict[str, Any] = {'sniff': True, 'meta': []}
|
||||
box: dict[str, _FakeSession] = {}
|
||||
client = _FakeClient()
|
||||
|
||||
def install(responder):
|
||||
box['session'] = _FakeSession(responder)
|
||||
return box['session']
|
||||
client.set_responder(responder)
|
||||
return client
|
||||
|
||||
def _record_meta(self, file_path, music_info, fields=None):
|
||||
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.get_http_session',
|
||||
lambda: box['session'])
|
||||
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'])
|
||||
@@ -126,48 +149,48 @@ def env(tmp_path, monkeypatch):
|
||||
# ============ 正常落盘 ============
|
||||
|
||||
|
||||
def test_success_promotes_part_to_final(env):
|
||||
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 = dl.download_music_file(101, music_info=info)
|
||||
r = await dl.download_music_file(101, music_info=info)
|
||||
|
||||
assert r.success and r.error_message == ''
|
||||
assert Path(r.file_path) == fp
|
||||
assert Path(r.file_path or '') == fp
|
||||
assert r.file_size == 20 and fp.read_bytes() == b'x' * 20
|
||||
assert not part.exists(), '完成后 .part 应改名转正'
|
||||
|
||||
|
||||
def test_empty_chunks_skipped(env):
|
||||
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 = dl.download_music_file(101, music_info=info,
|
||||
progress_cb=lambda w, _t: seen.append(w))
|
||||
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], '空分块不写盘也不触发进度回调'
|
||||
|
||||
|
||||
def test_progress_reports_resume_start_then_each_chunk(env):
|
||||
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 = dl.download_music_file(101, music_info=info,
|
||||
progress_cb=lambda w, t: seen.append((w, t)))
|
||||
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)], '起始回调带续传起点,此后每块一次'
|
||||
|
||||
|
||||
def test_progress_callback_exception_never_breaks_download(env):
|
||||
async def test_progress_callback_exception_never_breaks_download(env):
|
||||
dl, _state, install = env
|
||||
info = _info(size=20)
|
||||
install(_body(20))
|
||||
@@ -175,20 +198,24 @@ def test_progress_callback_exception_never_breaks_download(env):
|
||||
def _boom(_w, _t):
|
||||
raise RuntimeError('回调炸了')
|
||||
|
||||
r = dl.download_music_file(101, music_info=info, progress_cb=_boom)
|
||||
r = await dl.download_music_file(101, music_info=info, progress_cb=_boom)
|
||||
|
||||
assert r.success, '进度回调异常绝不影响下载主流程'
|
||||
|
||||
|
||||
def test_music_info_fetched_when_not_provided(env, monkeypatch):
|
||||
async def test_music_info_fetched_when_not_provided(env, monkeypatch):
|
||||
dl, _state, install = env
|
||||
info = _info(size=20)
|
||||
seen: list[tuple] = []
|
||||
monkeypatch.setattr(dl, 'get_music_info',
|
||||
lambda mid, q, c=None: (seen.append((mid, q)), info)[1])
|
||||
|
||||
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 = dl.download_music_file(101, 'lossless')
|
||||
r = await dl.download_music_file(101, 'lossless')
|
||||
|
||||
assert r.success and seen == [(101, 'lossless')]
|
||||
|
||||
@@ -196,40 +223,40 @@ def test_music_info_fetched_when_not_provided(env, monkeypatch):
|
||||
# ============ 缓存校验 ============
|
||||
|
||||
|
||||
def test_cache_hit_skips_network(env):
|
||||
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 = dl.download_music_file(101, music_info=info)
|
||||
r = await dl.download_music_file(101, music_info=info)
|
||||
|
||||
assert r.success and r.file_size == 20
|
||||
assert session.calls == []
|
||||
|
||||
|
||||
def test_cache_size_mismatch_triggers_redownload(env):
|
||||
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 = dl.download_music_file(101, music_info=info)
|
||||
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, '坏缓存应被删除并重下,而不是当结果交付'
|
||||
|
||||
|
||||
def test_zero_byte_cache_rejected_when_expected_size_unknown(env):
|
||||
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 = dl.download_music_file(101, music_info=info)
|
||||
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 字节坏文件'
|
||||
@@ -238,7 +265,7 @@ def test_zero_byte_cache_rejected_when_expected_size_unknown(env):
|
||||
# ============ 断点续传 ============
|
||||
|
||||
|
||||
def test_resume_sends_range_and_appends(env):
|
||||
async def test_resume_sends_range_and_appends(env):
|
||||
dl, _state, install = env
|
||||
info = _info(size=20)
|
||||
fp, part = _paths(dl, info)
|
||||
@@ -247,67 +274,67 @@ def test_resume_sends_range_and_appends(env):
|
||||
[b'q' * 10], status=206, headers={'Content-Range': 'bytes 10-19/20'}))
|
||||
seen: list[tuple[int, int]] = []
|
||||
|
||||
r = dl.download_music_file(101, music_info=info,
|
||||
progress_cb=lambda w, t: seen.append((w, t)))
|
||||
r = await dl.download_music_file(101, music_info=info,
|
||||
progress_cb=lambda w, t: seen.append((w, t)))
|
||||
|
||||
assert r.success
|
||||
assert session.calls[0][1]['headers'].get('Range') == 'bytes=10-'
|
||||
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), '起始进度应带续传起点'
|
||||
|
||||
|
||||
def test_server_ignoring_range_rewrites_from_zero(env):
|
||||
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 = dl.download_music_file(101, music_info=info)
|
||||
r = await dl.download_music_file(101, music_info=info)
|
||||
|
||||
assert r.success and fp.read_bytes() == b'r' * 20, '服务器返回 200 须从头重写'
|
||||
|
||||
|
||||
def test_complete_part_promoted_without_network(env):
|
||||
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 = dl.download_music_file(101, music_info=info)
|
||||
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()
|
||||
|
||||
|
||||
def test_oversized_part_discarded_starts_from_zero(env):
|
||||
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 = dl.download_music_file(101, music_info=info)
|
||||
r = await dl.download_music_file(101, music_info=info)
|
||||
|
||||
assert r.success
|
||||
assert 'Range' not in session.calls[0][1]['headers']
|
||||
assert 'Range' not in _headers(session.calls[0][1])
|
||||
|
||||
|
||||
def test_zero_byte_part_discarded(env):
|
||||
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 = dl.download_music_file(101, music_info=info)
|
||||
r = await dl.download_music_file(101, music_info=info)
|
||||
|
||||
assert r.success and 'Range' not in session.calls[0][1]['headers']
|
||||
assert r.success and 'Range' not in _headers(session.calls[0][1])
|
||||
|
||||
|
||||
def test_content_range_offset_mismatch_restarts_from_scratch(env):
|
||||
async def test_content_range_offset_mismatch_restarts_from_scratch(env):
|
||||
dl, _state, install = env
|
||||
info = _info(size=20)
|
||||
fp, part = _paths(dl, info)
|
||||
@@ -321,14 +348,14 @@ def test_content_range_offset_mismatch_restarts_from_scratch(env):
|
||||
|
||||
session = install(responder)
|
||||
|
||||
r = dl.download_music_file(101, music_info=info, retries=1)
|
||||
r = await dl.download_music_file(101, music_info=info, retries=1)
|
||||
|
||||
assert r.success and len(session.calls) == 2
|
||||
assert 'Range' not in session.calls[1][1]['headers'], '清残片后应从头下'
|
||||
assert 'Range' not in _headers(session.calls[1][1]), '清残片后应从头下'
|
||||
assert fp.read_bytes() == b'f' * 20
|
||||
|
||||
|
||||
def test_206_without_content_range_restarts_from_scratch(env):
|
||||
async def test_206_without_content_range_restarts_from_scratch(env):
|
||||
dl, _state, install = env
|
||||
info = _info(size=20)
|
||||
_fp, part = _paths(dl, info)
|
||||
@@ -341,13 +368,13 @@ def test_206_without_content_range_restarts_from_scratch(env):
|
||||
|
||||
session = install(responder)
|
||||
|
||||
r = dl.download_music_file(101, music_info=info, retries=1)
|
||||
r = await dl.download_music_file(101, music_info=info, retries=1)
|
||||
|
||||
assert r.success and len(session.calls) == 2
|
||||
assert 'Range' not in session.calls[1][1]['headers']
|
||||
assert 'Range' not in _headers(session.calls[1][1])
|
||||
|
||||
|
||||
def test_http_416_restarts_from_scratch(env):
|
||||
async def test_http_416_restarts_from_scratch(env):
|
||||
dl, _state, install = env
|
||||
info = _info(size=20)
|
||||
fp, part = _paths(dl, info)
|
||||
@@ -360,45 +387,45 @@ def test_http_416_restarts_from_scratch(env):
|
||||
|
||||
session = install(responder)
|
||||
|
||||
r = dl.download_music_file(101, music_info=info, retries=1)
|
||||
r = await dl.download_music_file(101, music_info=info, retries=1)
|
||||
|
||||
assert r.success and len(session.calls) == 2
|
||||
assert 'Range' not in session.calls[1][1]['headers']
|
||||
assert 'Range' not in _headers(session.calls[1][1])
|
||||
assert fp.read_bytes() == b'f' * 20
|
||||
|
||||
|
||||
# ============ 失败与重试 ============
|
||||
|
||||
|
||||
def test_cdn_4xx_is_deterministic_and_not_retried(env):
|
||||
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:
|
||||
dl.download_music_file(101, music_info=info, retries=2)
|
||||
await dl.download_music_file(101, music_info=info, retries=2)
|
||||
|
||||
assert 'HTTP 403' in str(ei.value)
|
||||
assert len(session.calls) == 1, '确定性失败对同一失效直链白重试毫无意义'
|
||||
|
||||
|
||||
def test_408_is_retried_then_reported_as_failure(env):
|
||||
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 = dl.download_music_file(101, music_info=info, retries=1)
|
||||
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 请求超时应保留可重试'
|
||||
|
||||
|
||||
def test_5xx_is_retried_and_keeps_part(env):
|
||||
async def test_5xx_is_retried_and_keeps_part(env):
|
||||
"""5xx 是服务端瞬时故障(非确定性失败):应可重试,且保留 .part 供续传。
|
||||
|
||||
与 4xx 的分界在这里钉住:error 分类只看 400<=status<500,5xx 落回裸重抛,
|
||||
由外层 RequestException 分支退避重试。此前这条路径无测试覆盖。
|
||||
由外层 UpstreamError 分支退避重试。此前这条路径无测试覆盖。
|
||||
"""
|
||||
dl, _state, install = env
|
||||
info = _info(size=100)
|
||||
@@ -406,7 +433,7 @@ def test_5xx_is_retried_and_keeps_part(env):
|
||||
part.write_bytes(b'p' * 10)
|
||||
session = install(lambda a, u, kw: _FakeResponse([], status=500))
|
||||
|
||||
r = dl.download_music_file(101, music_info=info, retries=1)
|
||||
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 非确定性失败,应重试'
|
||||
@@ -414,20 +441,20 @@ def test_5xx_is_retried_and_keeps_part(env):
|
||||
assert part.stat().st_size == 10, '.part 应保留供下轮续传'
|
||||
|
||||
|
||||
def test_incomplete_size_keeps_part_and_reports(env):
|
||||
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 = dl.download_music_file(101, music_info=info, retries=0)
|
||||
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(), '不完整的下载不得转正交付'
|
||||
|
||||
|
||||
def test_incomplete_then_resumes_on_next_attempt(env):
|
||||
async def test_incomplete_then_resumes_on_next_attempt(env):
|
||||
dl, _state, install = env
|
||||
info = _info(size=100)
|
||||
_fp, part = _paths(dl, info)
|
||||
@@ -440,25 +467,24 @@ def test_incomplete_then_resumes_on_next_attempt(env):
|
||||
|
||||
session = install(responder)
|
||||
|
||||
r = dl.download_music_file(101, music_info=info, retries=1)
|
||||
r = await dl.download_music_file(101, music_info=info, retries=1)
|
||||
|
||||
assert not r.success and '下载不完整' in r.error_message
|
||||
assert session.calls[1][1]['headers'].get('Range') == 'bytes=10-', '下轮应续传'
|
||||
assert _headers(session.calls[1][1]).get('Range') == 'bytes=10-', '下轮应续传'
|
||||
assert part.stat().st_size == 15
|
||||
|
||||
|
||||
def test_network_error_keeps_part_for_resume(env):
|
||||
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)
|
||||
|
||||
def chunks():
|
||||
yield b'a' * 10
|
||||
raise sync_http.RequestException('连接中断')
|
||||
install(lambda a, u, kw: _FakeResponse(
|
||||
[b'a' * 10, aiohttp.ClientPayloadError('连接中断')]))
|
||||
|
||||
install(lambda a, u, kw: _FakeResponse(chunks()))
|
||||
|
||||
r = dl.download_music_file(101, music_info=info, retries=0)
|
||||
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'
|
||||
@@ -468,7 +494,7 @@ def test_network_error_keeps_part_for_resume(env):
|
||||
# ============ 内容校验与体积上限 ============
|
||||
|
||||
|
||||
def test_unknown_size_non_audio_content_rejected(env):
|
||||
async def test_unknown_size_non_audio_content_rejected(env):
|
||||
dl, state, install = env
|
||||
info = _info(size=0)
|
||||
fp, part = _paths(dl, info)
|
||||
@@ -476,25 +502,25 @@ def test_unknown_size_non_audio_content_rejected(env):
|
||||
install(lambda a, u, kw: _FakeResponse([b'<html>blocked</html>']))
|
||||
|
||||
with pytest.raises(DownloadException) as ei:
|
||||
dl.download_music_file(101, music_info=info)
|
||||
await dl.download_music_file(101, music_info=info)
|
||||
|
||||
assert '非音频数据' in str(ei.value)
|
||||
assert not part.exists() and not fp.exists()
|
||||
|
||||
|
||||
def test_unknown_size_audio_content_accepted(env):
|
||||
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 = dl.download_music_file(101, music_info=info)
|
||||
r = await dl.download_music_file(101, music_info=info)
|
||||
|
||||
assert r.success and fp.read_bytes() == b'ID3audio'
|
||||
|
||||
|
||||
def test_size_limit_discards_part_and_fails(env, monkeypatch):
|
||||
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)
|
||||
@@ -502,7 +528,7 @@ def test_size_limit_discards_part_and_fails(env, monkeypatch):
|
||||
install(lambda a, u, kw: _FakeResponse([b'x' * 100]))
|
||||
|
||||
with pytest.raises(DownloadException) as ei:
|
||||
dl.download_music_file(101, music_info=info)
|
||||
await dl.download_music_file(101, music_info=info)
|
||||
|
||||
assert '大小上限' in str(ei.value)
|
||||
assert not part.exists() and not fp.exists(), '超限残片无续传价值,须清理'
|
||||
@@ -511,7 +537,7 @@ def test_size_limit_discards_part_and_fails(env, monkeypatch):
|
||||
# ============ 取消 ============
|
||||
|
||||
|
||||
def test_cancel_before_transfer_clears_part_and_skips_network(env):
|
||||
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)
|
||||
@@ -521,51 +547,42 @@ def test_cancel_before_transfer_clears_part_and_skips_network(env):
|
||||
session = install(lambda *a: pytest.fail('已取消不应发网络请求'))
|
||||
|
||||
with pytest.raises(DownloadCanceled):
|
||||
dl.download_music_file(101, music_info=info, cancel_event=cancel)
|
||||
await dl.download_music_file(101, music_info=info, cancel_event=cancel)
|
||||
|
||||
assert session.calls == []
|
||||
assert not part.exists(), '取消须清掉 .part'
|
||||
|
||||
|
||||
def test_cancel_midway_clears_half_written_file(env):
|
||||
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()
|
||||
|
||||
def chunks():
|
||||
yield b'a' * 5
|
||||
cancel.set()
|
||||
yield b'b' * 15
|
||||
|
||||
install(lambda a, u, kw: _FakeResponse(chunks()))
|
||||
# 第二块产出**之前**置取消位:取消发生在迭代过程中(.part 已建立)
|
||||
install(lambda a, u, kw: _FakeResponse([b'a' * 5, cancel.set, b'b' * 15]))
|
||||
|
||||
with pytest.raises(DownloadCanceled):
|
||||
dl.download_music_file(101, music_info=info, cancel_event=cancel)
|
||||
await dl.download_music_file(101, music_info=info, cancel_event=cancel)
|
||||
|
||||
assert not part.exists() and not fp.exists()
|
||||
|
||||
|
||||
def test_network_error_while_canceled_reports_canceled(env):
|
||||
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()
|
||||
|
||||
def chunks():
|
||||
yield b'a' * 5
|
||||
cancel.set()
|
||||
raise OSError('句柄异常')
|
||||
|
||||
install(lambda a, u, kw: _FakeResponse(chunks()))
|
||||
install(lambda a, u, kw: _FakeResponse([b'a' * 5, cancel.set, OSError('句柄异常')]))
|
||||
|
||||
with pytest.raises(DownloadCanceled):
|
||||
dl.download_music_file(101, music_info=info, cancel_event=cancel, retries=2)
|
||||
await dl.download_music_file(101, music_info=info, cancel_event=cancel, retries=2)
|
||||
|
||||
assert not part.exists() and not fp.exists(), '已取消时残片没有续传价值'
|
||||
|
||||
|
||||
def test_cancel_after_promote_keeps_file_but_skips_metadata(env):
|
||||
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)
|
||||
@@ -578,8 +595,8 @@ def test_cancel_after_promote_keeps_file_but_skips_metadata(env):
|
||||
install(_body(20))
|
||||
|
||||
with pytest.raises(DownloadCanceled):
|
||||
dl.download_music_file(101, music_info=info, cancel_event=cancel,
|
||||
progress_cb=cb)
|
||||
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'] == [], '取消后不应再写元数据'
|
||||
@@ -588,14 +605,14 @@ def test_cancel_after_promote_keeps_file_but_skips_metadata(env):
|
||||
# ============ 元数据 ============
|
||||
|
||||
|
||||
def test_metadata_written_only_when_requested(env):
|
||||
async def test_metadata_written_only_when_requested(env):
|
||||
dl, state, install = env
|
||||
install(_body(20))
|
||||
|
||||
dl.download_music_file(101, music_info=_info(mid=101), write_meta=False)
|
||||
await dl.download_music_file(101, music_info=_info(mid=101), write_meta=False)
|
||||
assert state['meta'] == [], 'write_meta=False 不应写元数据'
|
||||
|
||||
r = dl.download_music_file(102, music_info=_info(mid=102), write_meta=True,
|
||||
meta_fields=['title'])
|
||||
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 应原样透传'
|
||||
+32
-23
@@ -180,23 +180,39 @@ def test_reap_retries_then_gives_up(sandbox):
|
||||
|
||||
# ============ ② 超时任务取消 ============
|
||||
|
||||
def _finished_thread() -> threading.Thread:
|
||||
"""已结束的线程替身——对应生产里 worker 崩溃/退出的情形(is_alive() 为 False)。"""
|
||||
th = threading.Thread(target=lambda: None)
|
||||
th.start()
|
||||
th.join()
|
||||
return th
|
||||
class _WorkerHandle:
|
||||
"""worker 句柄替身——生产里是 asyncio.Task,监控只看它的 ``done()``。
|
||||
|
||||
这里用轻量替身而不是真造一个已结束的 Task:Task 的构造依赖运行中的事件循环,
|
||||
而监控循环跑在常驻线程里、与任务所在的循环无关;本文件测的是「句柄状态 →
|
||||
是否代结算」这段判断,只要 done() 可控即可。
|
||||
"""
|
||||
|
||||
def __init__(self, done: bool) -> None:
|
||||
self._done = done
|
||||
|
||||
def done(self) -> bool:
|
||||
return self._done
|
||||
|
||||
|
||||
def _finished_worker() -> _WorkerHandle:
|
||||
"""已结束的 worker 替身——对应生产里 worker 崩溃/退出的情形(done() 为 True)。"""
|
||||
return _WorkerHandle(True)
|
||||
|
||||
|
||||
def _live_worker() -> _WorkerHandle:
|
||||
return _WorkerHandle(False)
|
||||
|
||||
|
||||
def test_cancel_overtime_settles_dead_worker(sandbox, monkeypatch):
|
||||
"""超硬上限且 worker 线程已死:置取消、唤醒 cancel Event、代为退款。"""
|
||||
"""超硬上限且 worker 已结束:置取消、唤醒 cancel Event、代为退款。"""
|
||||
settled: list[tuple] = []
|
||||
monkeypatch.setattr(auth, 'ledger_settle',
|
||||
lambda tid, ok, st: settled.append((tid, ok, st)))
|
||||
|
||||
task = _add_task('t-slow', status='running',
|
||||
start=time.time() - _HARD_TIMEOUT - 60,
|
||||
worker_thread=_finished_thread())
|
||||
worker_task=_finished_worker())
|
||||
services_tasks._cancel_overtime_tasks(time.time())
|
||||
|
||||
assert task['status'] == 'canceled'
|
||||
@@ -204,9 +220,9 @@ def test_cancel_overtime_settles_dead_worker(sandbox, monkeypatch):
|
||||
assert settled == [('t-slow', 0, 'timeout')]
|
||||
|
||||
|
||||
def test_cancel_overtime_without_worker_thread_does_not_settle(sandbox, monkeypatch):
|
||||
"""无 worker_thread 字段的任务不代结算——代结算的前提是「线程曾存在且已死」,
|
||||
没有线程句柄就无法判断 worker 是否已在别处结算过,贸然退款会双退。"""
|
||||
def test_cancel_overtime_without_worker_task_does_not_settle(sandbox, monkeypatch):
|
||||
"""无 worker_task 字段的任务不代结算——代结算的前提是「句柄曾存在且已结束」,
|
||||
没有句柄就无法判断 worker 是否已在别处结算过,贸然退款会双退。"""
|
||||
settled: list[tuple] = []
|
||||
monkeypatch.setattr(auth, 'ledger_settle',
|
||||
lambda tid, ok, st: settled.append((tid, ok, st)))
|
||||
@@ -225,18 +241,11 @@ def test_cancel_overtime_skips_live_worker(sandbox, monkeypatch):
|
||||
monkeypatch.setattr(auth, 'ledger_settle',
|
||||
lambda tid, ok, st: settled.append((tid, ok, st)))
|
||||
|
||||
stop = threading.Event()
|
||||
alive = threading.Thread(target=stop.wait, daemon=True)
|
||||
alive.start()
|
||||
try:
|
||||
task = _add_task('t-live', status='running',
|
||||
start=time.time() - _HARD_TIMEOUT - 60, worker_thread=alive)
|
||||
services_tasks._cancel_overtime_tasks(time.time())
|
||||
assert task['status'] == 'canceled'
|
||||
assert settled == [], 'worker 活着 → 不应代结算'
|
||||
finally:
|
||||
stop.set()
|
||||
alive.join(timeout=5)
|
||||
task = _add_task('t-live', status='running',
|
||||
start=time.time() - _HARD_TIMEOUT - 60, worker_task=_live_worker())
|
||||
services_tasks._cancel_overtime_tasks(time.time())
|
||||
assert task['status'] == 'canceled'
|
||||
assert settled == [], 'worker 活着 → 不应代结算'
|
||||
|
||||
|
||||
def test_cancel_overtime_ignores_recent(sandbox):
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
断言只看「调用后世界里发生了什么」(文件系统 + 任务表),不引用私有函数名,
|
||||
故可跨后续重构复用。
|
||||
|
||||
替身边界:monkeypatch ``services.podcast._http``(core.http_client 的进程级异步
|
||||
客户端),测试用 ``holder['resp']`` 指定本次响应。
|
||||
"""
|
||||
import threading
|
||||
|
||||
@@ -18,8 +21,19 @@ from music_downloader import DownloadCanceled, DownloadException
|
||||
from services import podcast as podcast_svc
|
||||
|
||||
|
||||
class _FakeContent:
|
||||
"""``aiohttp.ClientResponse.content`` 的替身(逐块产出,惰性求值)。"""
|
||||
|
||||
def __init__(self, chunks):
|
||||
self._chunks = chunks
|
||||
|
||||
async def iter_chunked(self, chunk_size=0):
|
||||
for item in self._chunks:
|
||||
yield item
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
"""`_stream_open` 的替身:可控状态码、Content-Length 与分块序列。
|
||||
"""``core.http_client.open`` 的替身:可控状态码、Content-Length 与分块序列。
|
||||
|
||||
注意不能用 list(chunks) 归一化——那会把传入的生成器**立即求值**,使「迭代中
|
||||
才触发的副作用」(如中途置取消位)提前到构造时刻,测出来的就不是想测的路径。
|
||||
@@ -28,16 +42,13 @@ class _FakeResp:
|
||||
def __init__(self, chunks, status=200, content_length=None):
|
||||
self._chunks = chunks
|
||||
self.status = status
|
||||
self.reason = ''
|
||||
self.headers = {} if content_length is None else {'Content-Length': str(content_length)}
|
||||
self.content = _FakeContent(chunks)
|
||||
self.closed = False
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def iter_content(self, chunk_size=0):
|
||||
yield from self._chunks
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _CancelAfterFirstChunk:
|
||||
@@ -66,14 +77,15 @@ class _CancelAfterFirstChunk:
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_stream(monkeypatch):
|
||||
"""替换 _stream_open;测试通过 holder['resp'] 指定本次响应。"""
|
||||
"""替换 podcast 模块的 _http 客户端;测试通过 holder['resp'] 指定本次响应。"""
|
||||
holder: dict = {}
|
||||
|
||||
def _open(url, timeout=60):
|
||||
assert url == holder.get('url', url)
|
||||
return holder['resp']
|
||||
class _Client:
|
||||
async def open(self, method, url, **kw):
|
||||
assert url == holder.get('url', url)
|
||||
return holder['resp']
|
||||
|
||||
monkeypatch.setattr(podcast_svc, '_stream_open', _open)
|
||||
monkeypatch.setattr(podcast_svc, '_http', _Client())
|
||||
return holder
|
||||
|
||||
|
||||
@@ -97,91 +109,93 @@ def task_table():
|
||||
# ============ 正常路径 ============
|
||||
|
||||
|
||||
def test_writes_target_and_renames_part(tmp_path, fake_stream):
|
||||
async def test_writes_target_and_renames_part(tmp_path, fake_stream):
|
||||
"""正常下载:返回实际字节数,落地 target,不残留 .part。"""
|
||||
fake_stream['resp'] = _FakeResp([b'a' * 600, b'b' * 400])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
done = podcast_svc._podcast_stream_download('http://u', target, 1000, threading.Event())
|
||||
done = await podcast_svc._podcast_stream_download('http://u', target, 1000, threading.Event())
|
||||
assert done == 1000
|
||||
assert target.read_bytes() == b'a' * 600 + b'b' * 400
|
||||
assert not target.with_name('ep.mp3.part').exists()
|
||||
|
||||
|
||||
def test_empty_chunks_are_skipped(tmp_path, fake_stream):
|
||||
async def test_empty_chunks_are_skipped(tmp_path, fake_stream):
|
||||
"""空分块不计入字节数(上游偶发发空 chunk)。"""
|
||||
fake_stream['resp'] = _FakeResp([b'', b'x' * 50, b''])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
assert podcast_svc._podcast_stream_download('http://u', target, 50, threading.Event()) == 50
|
||||
assert await podcast_svc._podcast_stream_download(
|
||||
'http://u', target, 50, threading.Event()) == 50
|
||||
|
||||
|
||||
def test_content_length_used_when_total_unknown(tmp_path, fake_stream):
|
||||
async def test_content_length_used_when_total_unknown(tmp_path, fake_stream):
|
||||
"""未传期望大小时用 Content-Length 兜底,并据此校验完整性。"""
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 30], content_length=30)
|
||||
target = tmp_path / 'ep.mp3'
|
||||
assert podcast_svc._podcast_stream_download('http://u', target, 0, threading.Event()) == 30
|
||||
assert await podcast_svc._podcast_stream_download(
|
||||
'http://u', target, 0, threading.Event()) == 30
|
||||
|
||||
|
||||
# ============ 完整性校验 ============
|
||||
|
||||
|
||||
def test_size_mismatch_removes_delivered_file(tmp_path, fake_stream):
|
||||
async def test_size_mismatch_removes_delivered_file(tmp_path, fake_stream):
|
||||
"""字节数不符:删掉已落地的文件并报错(不交付截断的坏文件)。"""
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 100])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadException) as ei:
|
||||
podcast_svc._podcast_stream_download('http://u', target, 200, threading.Event())
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 200, threading.Event())
|
||||
assert '下载不完整' in str(ei.value)
|
||||
assert not target.exists(), '坏文件必须被清掉'
|
||||
|
||||
|
||||
def test_empty_download_raises_and_removes(tmp_path, fake_stream):
|
||||
async def test_empty_download_raises_and_removes(tmp_path, fake_stream):
|
||||
"""期望大小未知且一个字节都没拿到 → 报「下载内容为空」。"""
|
||||
fake_stream['resp'] = _FakeResp([])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadException) as ei:
|
||||
podcast_svc._podcast_stream_download('http://u', target, 0, threading.Event())
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 0, threading.Event())
|
||||
assert '下载内容为空' in str(ei.value)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_known_size_does_not_tolerate_partial(tmp_path, fake_stream):
|
||||
async def test_known_size_does_not_tolerate_partial(tmp_path, fake_stream):
|
||||
"""已知期望大小时要求精确相等——95% 容差会放行截断的坏文件。"""
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 95])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadException):
|
||||
podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
|
||||
|
||||
|
||||
# ============ 上游与取消 ============
|
||||
|
||||
|
||||
def test_upstream_error_status_raises(tmp_path, fake_stream):
|
||||
async def test_upstream_error_status_raises(tmp_path, fake_stream):
|
||||
fake_stream['resp'] = _FakeResp([], status=404)
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadException) as ei:
|
||||
podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
|
||||
assert '上游返回 404' in str(ei.value)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_cancel_before_first_chunk(tmp_path, fake_stream):
|
||||
async def test_cancel_before_first_chunk(tmp_path, fake_stream):
|
||||
"""取消已置位:抛 DownloadCanceled,不发布 target。"""
|
||||
cancel = threading.Event()
|
||||
cancel.set()
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 10])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadCanceled):
|
||||
podcast_svc._podcast_stream_download('http://u', target, 10, cancel)
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 10, cancel)
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_cancel_midway_leaves_part_file(tmp_path, fake_stream):
|
||||
async def test_cancel_midway_leaves_part_file(tmp_path, fake_stream):
|
||||
"""下载中途取消:.part 残留(不删)——改名不发生,下次同名下载直接覆盖。"""
|
||||
cancel = threading.Event()
|
||||
fake_stream['resp'] = _FakeResp(_CancelAfterFirstChunk(cancel))
|
||||
target = tmp_path / 'ep.mp3'
|
||||
with pytest.raises(DownloadCanceled):
|
||||
podcast_svc._podcast_stream_download('http://u', target, 20, cancel)
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 20, cancel)
|
||||
assert not target.exists()
|
||||
assert target.with_name('ep.mp3.part').exists(), '既有行为:取消时 .part 留存'
|
||||
|
||||
@@ -189,7 +203,7 @@ def test_cancel_midway_leaves_part_file(tmp_path, fake_stream):
|
||||
# ============ 进度消息 ============
|
||||
|
||||
|
||||
def test_progress_written_for_running_single_task(tmp_path, fake_stream, task_table):
|
||||
async def test_progress_written_for_running_single_task(tmp_path, fake_stream, task_table):
|
||||
"""running 的单集任务:写 msg,并按字节级口径写 live_percent。
|
||||
|
||||
单块即完成 → 百分比被 min(99, ...) 封在 99(进度条不预支 100%,收尾靠终态)。
|
||||
@@ -197,24 +211,25 @@ def test_progress_written_for_running_single_task(tmp_path, fake_stream, task_ta
|
||||
task = task_table()
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 100])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event(),
|
||||
task_id=task['id'], progress_label='下载中')
|
||||
await podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event(),
|
||||
task_id=task['id'], progress_label='下载中')
|
||||
assert '下载中' in task['msg'] and '99%' in task['msg']
|
||||
assert task['live_percent'] == 99.0
|
||||
|
||||
|
||||
def test_progress_not_written_for_finished_task(tmp_path, fake_stream, task_table):
|
||||
async def test_progress_not_written_for_finished_task(tmp_path, fake_stream, task_table):
|
||||
"""已终态的任务不再被写进度(避免覆盖终态消息),但下载照常完成。"""
|
||||
task = task_table(status='done', msg='原消息')
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 50])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
assert podcast_svc._podcast_stream_download('http://u', target, 50, threading.Event(),
|
||||
task_id=task['id']) == 50
|
||||
assert await podcast_svc._podcast_stream_download(
|
||||
'http://u', target, 50, threading.Event(), task_id=task['id']) == 50
|
||||
assert task['msg'] == '原消息'
|
||||
|
||||
|
||||
def test_progress_skipped_without_task_id(tmp_path, fake_stream):
|
||||
async def test_progress_skipped_without_task_id(tmp_path, fake_stream):
|
||||
"""task_id 为空时只下载不写任务表(不得因缺 id 而报错)。"""
|
||||
fake_stream['resp'] = _FakeResp([b'x' * 50])
|
||||
target = tmp_path / 'ep.mp3'
|
||||
assert podcast_svc._podcast_stream_download('http://u', target, 50, threading.Event()) == 50
|
||||
assert await podcast_svc._podcast_stream_download(
|
||||
'http://u', target, 50, threading.Event()) == 50
|
||||
@@ -19,6 +19,7 @@ except 分支各写一遍「结算 + 删任务目录」,正文里散着三处
|
||||
|
||||
账本口径:一律用 ``reserve_and_open`` 真预扣(与 test_single_worker.py 同理由)。
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
@@ -83,9 +84,9 @@ class _FakeStream:
|
||||
self.on_call = on_call
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def __call__(self, url: str, target: Path, total_size: int,
|
||||
cancel_ev: threading.Event, task_id: str = '',
|
||||
progress_label: str = '') -> int:
|
||||
async def __call__(self, url: str, target: Path, total_size: int,
|
||||
cancel_ev: threading.Event, task_id: str = '',
|
||||
progress_label: str = '') -> int:
|
||||
self.calls.append({'url': url, 'target': Path(target),
|
||||
'total_size': total_size})
|
||||
if self.on_call is not None:
|
||||
@@ -172,13 +173,20 @@ def _register_podcast(sandbox, *, program_id: int = 777, user_id: int,
|
||||
def _install_upstream(monkeypatch, *, program: dict[str, Any] | None = None,
|
||||
url_info: dict[str, Any] | None = None,
|
||||
stream: Any = None,
|
||||
cover: Any = lambda url: b'\xff\xd8\xff cover') -> None:
|
||||
cover: Any = None) -> None:
|
||||
"""装上播客上游替身(节目详情 / 直链 / 流式下载 / 封面)。"""
|
||||
monkeypatch.setattr(podcast_svc.podcast, 'get_program_detail',
|
||||
lambda pid, cookies: program if program is not None else _program())
|
||||
monkeypatch.setattr(podcast_svc.podcast, 'resolve_episode_url',
|
||||
lambda sid, cookies, quality:
|
||||
url_info if url_info is not None else _url_info())
|
||||
async def _detail(pid: Any, cookies: Any) -> dict[str, Any]:
|
||||
return program if program is not None else _program()
|
||||
|
||||
async def _resolve(sid: Any, cookies: Any, quality: str) -> dict[str, Any]:
|
||||
return url_info if url_info is not None else _url_info()
|
||||
|
||||
monkeypatch.setattr(podcast_svc.podcast, 'get_program_detail', _detail)
|
||||
monkeypatch.setattr(podcast_svc.podcast, 'resolve_episode_url', _resolve)
|
||||
if cover is None:
|
||||
async def _default_cover(_url: str) -> bytes:
|
||||
return b'\xff\xd8\xff cover'
|
||||
cover = _default_cover
|
||||
if stream is None:
|
||||
stream = _FakeStream()
|
||||
monkeypatch.setattr(podcast_svc, '_podcast_stream_download', stream)
|
||||
@@ -186,8 +194,11 @@ def _install_upstream(monkeypatch, *, program: dict[str, Any] | None = None,
|
||||
|
||||
|
||||
def _run(task_id: str) -> threading.Thread:
|
||||
th = threading.Thread(target=podcast_svc._podcast_download_worker,
|
||||
args=(task_id,), daemon=True)
|
||||
"""把 worker 协程跑在独立线程的私有事件循环里(等价于生产的任务循环)。"""
|
||||
def _target() -> None:
|
||||
asyncio.run(podcast_svc._podcast_download_worker(task_id))
|
||||
|
||||
th = threading.Thread(target=_target, daemon=True)
|
||||
th.start()
|
||||
return th
|
||||
|
||||
@@ -332,7 +343,7 @@ def test_cover_fetch_failure_still_delivers_zip(sandbox, monkeypatch):
|
||||
"""取封面抛异常:被吞掉,ZIP 照常交付(历史行为:封面失败不炸任务)。"""
|
||||
uid = _uid()
|
||||
|
||||
def _boom(url: str) -> bytes:
|
||||
async def _boom(url: str) -> bytes:
|
||||
raise RuntimeError('cover cdn down')
|
||||
|
||||
_install_upstream(monkeypatch, cover=_boom)
|
||||
@@ -402,7 +413,7 @@ def test_unexpected_exception_uses_generic_message(sandbox, monkeypatch):
|
||||
uid = _uid()
|
||||
before = _balance()
|
||||
|
||||
def _boom(pid: Any, cookies: Any) -> dict[str, Any]:
|
||||
async def _boom(pid: Any, cookies: Any) -> dict[str, Any]:
|
||||
raise RuntimeError('upstream host=10.0.0.9 refused')
|
||||
|
||||
_install_upstream(monkeypatch)
|
||||
@@ -468,7 +479,7 @@ def test_cancel_during_zip_stage(sandbox, monkeypatch):
|
||||
before = _balance()
|
||||
holder: dict[str, Any] = {}
|
||||
|
||||
def _cancel_in_cover(url: str) -> bytes:
|
||||
async def _cancel_in_cover(url: str) -> bytes:
|
||||
holder['task']['cancel'].set()
|
||||
return b'\xff\xd8\xff cover'
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
- 未知音质 → 该音质优先 + 通用阶梯
|
||||
- song_id 垃圾值 → None 且不发任何请求
|
||||
|
||||
替身边界:monkeypatch ``podcast.url_v1``(模块级名字,podcast.py 从
|
||||
.compat 导入后的绑定),按 level 返回结果或抛异常,并记录调用序列。
|
||||
替身边界:monkeypatch ``music_api.podcast.NeteaseAPI``——resolve_episode_url 内部
|
||||
现构造该客户端并 ``await api.get_song_url(...)``,替身按 level 返回结果或抛异常,
|
||||
并记录调用序列。
|
||||
"""
|
||||
import pytest
|
||||
|
||||
@@ -23,14 +24,18 @@ def url_calls(monkeypatch):
|
||||
calls: list[tuple[int, str]] = []
|
||||
state: dict = {'results': {}}
|
||||
|
||||
def _fake_url_v1(sid, level, cookies):
|
||||
async def _fake_get_song_url(sid, level, cookies):
|
||||
calls.append((sid, level))
|
||||
r = state['results'].get(level)
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return r
|
||||
|
||||
monkeypatch.setattr(podcast_mod, 'url_v1', _fake_url_v1)
|
||||
class _FakeAPI:
|
||||
async def get_song_url(self, sid, level, cookies):
|
||||
return await _fake_get_song_url(sid, level, cookies)
|
||||
|
||||
monkeypatch.setattr(podcast_mod, 'NeteaseAPI', _FakeAPI)
|
||||
state['calls'] = calls
|
||||
return state
|
||||
|
||||
@@ -42,38 +47,38 @@ def _ok(level: str = 'standard') -> dict:
|
||||
|
||||
async def test_auto_tries_quality_ladder_in_order(url_calls):
|
||||
"""auto:按 QUALITY_LADDER 原序尝试,全失败返回 None。"""
|
||||
assert resolve_episode_url(101, {}, 'auto') is None
|
||||
assert await resolve_episode_url(101, {}, 'auto') is None
|
||||
assert url_calls['calls'] == [(101, 'exhigh'), (101, 'standard')]
|
||||
|
||||
|
||||
async def test_explicit_level_falls_down_only(url_calls):
|
||||
"""显式 lossless:向后降级 exhigh/standard,绝不向上尝试 hires。"""
|
||||
assert resolve_episode_url(101, {}, 'lossless') is None
|
||||
assert await resolve_episode_url(101, {}, 'lossless') is None
|
||||
assert url_calls['calls'] == [(101, 'lossless'), (101, 'exhigh'), (101, 'standard')]
|
||||
|
||||
|
||||
async def test_standard_never_falls_up(url_calls):
|
||||
"""显式 standard:只试一次(历史坑:原实现会回退到更高的 exhigh)。"""
|
||||
assert resolve_episode_url(101, {}, 'standard') is None
|
||||
assert await resolve_episode_url(101, {}, 'standard') is None
|
||||
assert url_calls['calls'] == [(101, 'standard')]
|
||||
|
||||
|
||||
async def test_unknown_level_prepends_generic_ladder(url_calls):
|
||||
"""未知音质:先试该音质,再退通用阶梯(去重保序)。"""
|
||||
assert resolve_episode_url(101, {}, 'sky') is None
|
||||
assert await resolve_episode_url(101, {}, 'sky') is None
|
||||
assert url_calls['calls'] == [(101, 'sky'), (101, 'exhigh'), (101, 'standard')]
|
||||
|
||||
|
||||
async def test_first_hit_returns_mapped_result(url_calls):
|
||||
url_calls['results']['exhigh'] = _ok('hires')
|
||||
out = resolve_episode_url(101, {}, 'auto')
|
||||
out = await resolve_episode_url(101, {}, 'auto')
|
||||
assert out == {'url': 'https://x/1.mp3', 'level': 'hires', 'type': 'mp3',
|
||||
'size': 123, 'br': 128000, 'md5': 'abc', 'requested_level': 'exhigh'}
|
||||
|
||||
|
||||
async def test_level_defaults_to_requested_when_missing(url_calls):
|
||||
url_calls['results']['exhigh'] = {'data': [{'url': 'https://x/1.mp3'}]}
|
||||
out = resolve_episode_url(101, {}, 'auto')
|
||||
out = await resolve_episode_url(101, {}, 'auto')
|
||||
assert out is not None
|
||||
assert out['level'] == 'exhigh'
|
||||
assert out['type'] == 'mp3'
|
||||
@@ -84,7 +89,7 @@ async def test_level_defaults_to_requested_when_missing(url_calls):
|
||||
async def test_api_exception_tries_next_level(url_calls):
|
||||
url_calls['results']['exhigh'] = APIException('无版权')
|
||||
url_calls['results']['standard'] = _ok()
|
||||
out = resolve_episode_url(101, {}, 'auto')
|
||||
out = await resolve_episode_url(101, {}, 'auto')
|
||||
assert out is not None
|
||||
assert out['requested_level'] == 'standard'
|
||||
assert url_calls['calls'] == [(101, 'exhigh'), (101, 'standard')]
|
||||
@@ -94,7 +99,7 @@ async def test_missing_url_tries_next_level(url_calls):
|
||||
"""data[0] 无 url:视同失败,继续降级。"""
|
||||
url_calls['results']['exhigh'] = {'data': [{'url': None}]}
|
||||
url_calls['results']['standard'] = _ok()
|
||||
out = resolve_episode_url(101, {}, 'auto')
|
||||
out = await resolve_episode_url(101, {}, 'auto')
|
||||
assert out is not None
|
||||
assert out['requested_level'] == 'standard'
|
||||
|
||||
@@ -102,7 +107,7 @@ async def test_missing_url_tries_next_level(url_calls):
|
||||
async def test_empty_data_tries_next_level(url_calls):
|
||||
url_calls['results']['exhigh'] = None # res 为 None → data 为 []
|
||||
url_calls['results']['standard'] = _ok()
|
||||
out = resolve_episode_url(101, {}, 'auto')
|
||||
out = await resolve_episode_url(101, {}, 'auto')
|
||||
assert out is not None
|
||||
assert out['requested_level'] == 'standard'
|
||||
|
||||
@@ -110,12 +115,12 @@ async def test_empty_data_tries_next_level(url_calls):
|
||||
async def test_bad_song_id_returns_none_without_requests(url_calls):
|
||||
"""song_id 垃圾值:None,且不发任何请求(防御原 TypeError 500)。"""
|
||||
for bad in (None, 'abc', ''):
|
||||
assert resolve_episode_url(bad, {}, 'auto') is None
|
||||
assert await resolve_episode_url(bad, {}, 'auto') is None
|
||||
assert url_calls['calls'] == []
|
||||
|
||||
|
||||
async def test_numeric_string_song_id_accepted(url_calls):
|
||||
url_calls['results']['exhigh'] = _ok()
|
||||
out = resolve_episode_url(' 101 ', {}, 'auto')
|
||||
out = await resolve_episode_url(' 101 ', {}, 'auto')
|
||||
assert out is not None
|
||||
assert url_calls['calls'][0] == (101, 'exhigh')
|
||||
@@ -13,6 +13,7 @@
|
||||
账本口径:一律用 ``reserve_and_open`` 真预扣(``ledger_open`` 只记账不扣款,
|
||||
会让「退款」凭空给余额加次数,守恒断言就失去意义)。
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
@@ -62,7 +63,7 @@ class _FakeDownloader:
|
||||
self.info_calls = 0
|
||||
self.download_calls = 0
|
||||
|
||||
def get_music_info(self, mid: Any, quality: str, cookies: Any) -> MusicInfo:
|
||||
async def get_music_info(self, mid: Any, quality: str, cookies: Any) -> MusicInfo:
|
||||
self.info_calls += 1
|
||||
if self.info_error is not None:
|
||||
raise self.info_error
|
||||
@@ -73,7 +74,7 @@ class _FakeDownloader:
|
||||
quality='standard', lyric=self.lyric,
|
||||
)
|
||||
|
||||
def download_music_file(self, mid: Any, quality: str, **kw: Any) -> DownloadResult:
|
||||
async def download_music_file(self, mid: Any, quality: str, **kw: Any) -> DownloadResult:
|
||||
self.download_calls += 1
|
||||
if self.on_download is not None:
|
||||
self.on_download(kw)
|
||||
@@ -110,7 +111,10 @@ def sandbox(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(api_service, 'downloads_path', downloads)
|
||||
monkeypatch.setattr(api_service, 'logger', _NullLogger())
|
||||
# 封面走网络——测试里默认返回固定字节,个例可覆盖
|
||||
monkeypatch.setattr(downloads_svc, 'fetch_cover_bytes', lambda url: b'\xff\xd8\xff cover')
|
||||
async def _cover(_url: str) -> bytes:
|
||||
return b'\xff\xd8\xff cover'
|
||||
|
||||
monkeypatch.setattr(downloads_svc, 'fetch_cover_bytes', _cover)
|
||||
# 操作记录写进程内共享列表——隔离掉,断言改由个例捕获
|
||||
monkeypatch.setattr(downloads_svc, '_record_log', lambda *a: None)
|
||||
|
||||
@@ -167,8 +171,11 @@ def _register_single(sandbox, *, music_id: int = 1, user_id: int,
|
||||
|
||||
|
||||
def _run(task_id: str) -> threading.Thread:
|
||||
th = threading.Thread(target=downloads_svc._single_download_worker,
|
||||
args=(task_id,), daemon=True)
|
||||
"""把 worker 协程跑在独立线程的私有事件循环里(等价于生产的任务循环)。"""
|
||||
def _target() -> None:
|
||||
asyncio.run(downloads_svc._single_download_worker(task_id))
|
||||
|
||||
th = threading.Thread(target=_target, daemon=True)
|
||||
th.start()
|
||||
return th
|
||||
|
||||
@@ -275,7 +282,11 @@ def test_cover_failure_keeps_zip_without_cover(sandbox, monkeypatch):
|
||||
"""
|
||||
uid = _uid()
|
||||
monkeypatch.setattr(api_service, 'downloader', _FakeDownloader(sandbox['cache']))
|
||||
monkeypatch.setattr(downloads_svc, 'fetch_cover_bytes', lambda url: None)
|
||||
|
||||
async def _no_cover(_url: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(downloads_svc, 'fetch_cover_bytes', _no_cover)
|
||||
tid, task = _register_single(sandbox, music_id=105, user_id=uid, need_zip=True)
|
||||
th = _run(tid)
|
||||
th.join(timeout=30)
|
||||
|
||||
+31
-19
@@ -16,6 +16,7 @@ ZIP 内容、账本守恒、暂停/恢复/取消语义。断言只依赖「调
|
||||
账本口径:一律用 ``reserve_and_open`` 真预扣(``ledger_open`` 只记账不扣款,
|
||||
会让「退款」凭空给余额加次数,守恒断言就失去意义)。
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import zipfile
|
||||
@@ -59,7 +60,7 @@ class _FakeDownloader:
|
||||
self.calls: list[int] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get_music_info(self, mid: Any, quality: str, cookies: Any) -> MusicInfo:
|
||||
async def get_music_info(self, mid: Any, quality: str, cookies: Any) -> MusicInfo:
|
||||
return MusicInfo(
|
||||
id=int(mid), name=f'歌{mid}', artists='歌手A', album='专辑A',
|
||||
pic_url='', duration=200, track_number=1, download_url='',
|
||||
@@ -67,7 +68,7 @@ class _FakeDownloader:
|
||||
lyric='[00:01.00]第一行\n[00:02.00]第二行',
|
||||
)
|
||||
|
||||
def download_music_file(self, mid: Any, quality: str, **kw: Any) -> DownloadResult:
|
||||
async def download_music_file(self, mid: Any, quality: str, **kw: Any) -> DownloadResult:
|
||||
with self._lock:
|
||||
self.calls.append(int(mid))
|
||||
seq = len(self.calls)
|
||||
@@ -199,7 +200,11 @@ def _register_podcast(sandbox, pids: list[int], *, reserved: int, user_id: int)
|
||||
|
||||
|
||||
def _run(worker, task_id: str) -> threading.Thread:
|
||||
th = threading.Thread(target=worker, args=(task_id,), daemon=True)
|
||||
"""把 worker 协程跑在独立线程的私有事件循环里(等价于生产的任务循环)。"""
|
||||
def _target() -> None:
|
||||
asyncio.run(worker(task_id))
|
||||
|
||||
th = threading.Thread(target=_target, daemon=True)
|
||||
th.start()
|
||||
return th
|
||||
|
||||
@@ -346,8 +351,9 @@ def test_batch_zip_cancel_at_publish_checkpoint_delivers(sandbox, monkeypatch):
|
||||
holder: dict[str, Any] = {}
|
||||
real_settle = batch_svc._settle_batch_billing
|
||||
|
||||
def settle_then_cancel(task_id: str, ok_count: int, final_status: str = 'settled') -> int:
|
||||
refunded = real_settle(task_id, ok_count, final_status)
|
||||
async def settle_then_cancel(task_id: str, ok_count: int,
|
||||
final_status: str = 'settled') -> int:
|
||||
refunded = await real_settle(task_id, ok_count, final_status)
|
||||
holder['task']['cancel'].set() # 结算已完成、发布前一刻取消
|
||||
return refunded
|
||||
|
||||
@@ -371,24 +377,27 @@ def test_batch_zip_cancel_at_publish_checkpoint_delivers(sandbox, monkeypatch):
|
||||
@pytest.fixture()
|
||||
def podcast_stub(monkeypatch, sandbox):
|
||||
"""把播客上游与流式下载替换为本地确定性实现。"""
|
||||
def fake_detail(pid: Any, cookies: Any) -> dict[str, Any]:
|
||||
async def fake_detail(pid: Any, cookies: Any) -> dict[str, Any]:
|
||||
n = int(pid)
|
||||
return {'id': n, 'name': f'单集{n}', 'main_song_id': 1,
|
||||
'serial_num': n, 'cover_url': '', 'songs': []}
|
||||
|
||||
def fake_resolve(prog: dict[str, Any], quality: str, cookies: Any) -> dict[str, Any]:
|
||||
async def fake_resolve(prog: dict[str, Any], quality: str, cookies: Any) -> dict[str, Any]:
|
||||
return {'url': f"http://example.invalid/{prog['id']}.mp3", 'size': 2048, 'type': 'mp3'}
|
||||
|
||||
def fake_stream(url: str, target: Path, total_size: int, cancel_ev: Any,
|
||||
task_id: str = '', progress_label: str = '') -> int:
|
||||
async def fake_stream(url: str, target: Path, total_size: int, cancel_ev: Any,
|
||||
task_id: str = '', progress_label: str = '') -> int:
|
||||
if cancel_ev is not None and cancel_ev.is_set():
|
||||
raise DownloadCanceled('任务已取消')
|
||||
target.write_bytes(b'\xff\xfb\x90\x00' + b'\x00' * 128)
|
||||
return target.stat().st_size
|
||||
|
||||
async def _no_cover(_u: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(podcast_svc, '_podcast_resolve_audio', fake_resolve)
|
||||
monkeypatch.setattr(podcast_svc, '_podcast_stream_download', fake_stream)
|
||||
monkeypatch.setattr(podcast_svc, 'fetch_cover_bytes', lambda _u: None)
|
||||
monkeypatch.setattr(podcast_svc, 'fetch_cover_bytes', _no_cover)
|
||||
monkeypatch.setattr(podcast_svc.podcast, 'get_program_detail', fake_detail)
|
||||
return sandbox
|
||||
|
||||
@@ -418,7 +427,7 @@ def test_podcast_zip_pause_then_resume(podcast_stub, monkeypatch):
|
||||
holder: dict[str, Any] = {}
|
||||
fired = {'n': 0}
|
||||
|
||||
def _pause_once(url, target, total_size, cancel_ev, task_id='', progress_label=''):
|
||||
async def _pause_once(url, target, total_size, cancel_ev, task_id='', progress_label=''):
|
||||
target.write_bytes(b'\xff\xfb\x90\x00' + b'\x00' * 128)
|
||||
fired['n'] += 1
|
||||
if fired['n'] == 1: # 只在第一集触发——否则恢复后立刻又被暂停
|
||||
@@ -447,7 +456,7 @@ def test_podcast_zip_all_fail_refunds(podcast_stub, monkeypatch):
|
||||
uid = _uid()
|
||||
before = _balance()
|
||||
|
||||
def _boom(prog, quality, cookies):
|
||||
async def _boom(prog, quality, cookies):
|
||||
raise RuntimeError('上游不可用')
|
||||
|
||||
monkeypatch.setattr(podcast_svc, '_podcast_resolve_audio', _boom)
|
||||
@@ -470,9 +479,9 @@ def test_podcast_zip_all_fail_refunds(podcast_stub, monkeypatch):
|
||||
class _SameNameDownloader(_FakeDownloader):
|
||||
"""所有歌曲同名同歌手、且都带封面——用于验证包内三条目共享序号后缀。"""
|
||||
|
||||
def get_music_info(self, mid: Any, quality: str, cookies: Any) -> MusicInfo:
|
||||
return replace(super().get_music_info(mid, quality, cookies),
|
||||
name='同名歌', artists='同歌手',
|
||||
async def get_music_info(self, mid: Any, quality: str, cookies: Any) -> MusicInfo:
|
||||
base = await super().get_music_info(mid, quality, cookies)
|
||||
return replace(base, name='同名歌', artists='同歌手',
|
||||
pic_url='http://example.invalid/cover.jpg')
|
||||
|
||||
|
||||
@@ -485,7 +494,10 @@ def test_batch_zip_dedups_three_arcnames_with_shared_suffix(sandbox, monkeypatch
|
||||
"""
|
||||
uid = _uid()
|
||||
monkeypatch.setattr(api_service, 'downloader', _SameNameDownloader(sandbox['cache']))
|
||||
monkeypatch.setattr(batch_svc, 'fetch_cover_bytes', lambda _u: b'\xff\xd8\xff\xe0')
|
||||
async def _cover(_u: str) -> bytes:
|
||||
return b'\xff\xd8\xff\xe0'
|
||||
|
||||
monkeypatch.setattr(batch_svc, 'fetch_cover_bytes', _cover)
|
||||
tid, task = _register_batch(sandbox, [701, 702], reserved=2, user_id=uid)
|
||||
|
||||
th = _run(batch_svc._batch_zip_worker, tid)
|
||||
@@ -528,7 +540,7 @@ def test_podcast_all_fail_message_and_ledger_status(podcast_stub, monkeypatch):
|
||||
uid = _uid()
|
||||
before = _balance()
|
||||
|
||||
def _boom(prog, quality, cookies):
|
||||
async def _boom(prog, quality, cookies):
|
||||
raise RuntimeError('上游不可用')
|
||||
|
||||
monkeypatch.setattr(podcast_svc, '_podcast_resolve_audio', _boom)
|
||||
@@ -557,8 +569,8 @@ def test_podcast_cleans_temp_files_on_episode_failure(podcast_stub, monkeypatch)
|
||||
uid = _uid()
|
||||
before = _balance()
|
||||
|
||||
def _flaky(url: str, target: Path, total_size: int, cancel_ev: Any,
|
||||
task_id: str = '', progress_label: str = '') -> int:
|
||||
async def _flaky(url: str, target: Path, total_size: int, cancel_ev: Any,
|
||||
task_id: str = '', progress_label: str = '') -> int:
|
||||
if '/102.' in url:
|
||||
target.write_bytes(b'\x00' * 64) # 正文残留
|
||||
target.with_name(target.name + '.part').write_bytes(b'\x00' * 32) # 残片
|
||||
|
||||
Reference in new issue
Block a user