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_podcast_stream.py
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

236 lines
8.9 KiB
Python
Raw Permalink 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 -*-
"""`_podcast_stream_download` 的行为契约。
为什么:这是播客单集下载的**唯一落盘路径**(单集 worker 与整台打包的首集都走它),
却零测试覆盖。把 63 行 / 嵌套 10 层的实现拆成「拿名额 → 流式写 → 改名 → 校验」
四步后,需要行为级测试钉住可观察契约:返回字节数、半成品文件名、失败时是否清掉
已落地的坏文件、取消语义、进度消息写到哪。
断言只看「调用后世界里发生了什么」(文件系统 + 任务表),不引用私有函数名,
故可跨后续重构复用。
替身边界:monkeypatch ``services.podcast._http``(core.http_client 的进程级异步
客户端),测试用 ``holder['resp']`` 指定本次响应。
"""
import threading
import pytest
from core.state import DOWNLOAD_TASKS, DOWNLOAD_TASKS_LOCK
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:
"""``core.http_client.open`` 的替身:可控状态码、Content-Length 与分块序列。
注意不能用 list(chunks) 归一化——那会把传入的生成器**立即求值**,使「迭代中
才触发的副作用」(如中途置取消位)提前到构造时刻,测出来的就不是想测的路径。
"""
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 close(self):
self.closed = True
class _CancelAfterFirstChunk:
"""迭代器:吐第一块后置取消位,再吐第二块。
这样取消发生在**迭代过程中**(下载已开始、.part 已建立),与「开始前取消」
是两条不同的路径。
"""
def __init__(self, cancel):
self._cancel = cancel
self._n = 0
def __iter__(self):
return self
def __next__(self):
self._n += 1
if self._n == 1:
return b'x' * 10
if self._n == 2:
self._cancel.set()
return b'y' * 10
raise StopIteration
@pytest.fixture()
def fake_stream(monkeypatch):
"""替换 podcast 模块的 _http 客户端;测试通过 holder['resp'] 指定本次响应。"""
holder: dict = {}
class _Client:
async def open(self, method, url, **kw):
assert url == holder.get('url', url)
return holder['resp']
monkeypatch.setattr(podcast_svc, '_http', _Client())
return holder
@pytest.fixture()
def task_table():
"""在全局任务表里放一条任务,测试结束清理(该表是跨模块共享的进程内状态)。"""
tid = 'stream-test-task'
def _put(**kw):
task = {'id': tid, 'status': 'running', 'type': 'single', 'msg': ''}
task.update(kw)
with DOWNLOAD_TASKS_LOCK:
DOWNLOAD_TASKS[tid] = task
return task
yield _put
with DOWNLOAD_TASKS_LOCK:
DOWNLOAD_TASKS.pop(tid, None)
# ============ 正常路径 ============
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 = 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()
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 await podcast_svc._podcast_stream_download(
'http://u', target, 50, threading.Event()) == 50
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 await podcast_svc._podcast_stream_download(
'http://u', target, 0, threading.Event()) == 30
# ============ 完整性校验 ============
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:
await podcast_svc._podcast_stream_download('http://u', target, 200, threading.Event())
assert '下载不完整' in str(ei.value)
assert not target.exists(), '坏文件必须被清掉'
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:
await podcast_svc._podcast_stream_download('http://u', target, 0, threading.Event())
assert '下载内容为空' in str(ei.value)
assert not target.exists()
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):
await podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
# ============ 上游与取消 ============
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:
await podcast_svc._podcast_stream_download('http://u', target, 100, threading.Event())
assert '上游返回 404' in str(ei.value)
assert not target.exists()
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):
await podcast_svc._podcast_stream_download('http://u', target, 10, cancel)
assert not target.exists()
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):
await podcast_svc._podcast_stream_download('http://u', target, 20, cancel)
assert not target.exists()
assert target.with_name('ep.mp3.part').exists(), '既有行为:取消时 .part 留存'
# ============ 进度消息 ============
async def test_progress_written_for_running_single_task(tmp_path, fake_stream, task_table):
"""running 的单集任务:写 msg,并按字节级口径写 live_percent。
单块即完成 → 百分比被 min(99, ...) 封在 99(进度条不预支 100%,收尾靠终态)。
"""
task = task_table()
fake_stream['resp'] = _FakeResp([b'x' * 100])
target = tmp_path / 'ep.mp3'
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
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 await podcast_svc._podcast_stream_download(
'http://u', target, 50, threading.Event(), task_id=task['id']) == 50
assert task['msg'] == '原消息'
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 await podcast_svc._podcast_stream_download(
'http://u', target, 50, threading.Event()) == 50