snowy-yang
|
22b263f547
|
refactor: SMTP 发信改 aiosmtplib,最后一个阻塞网络调用从 to_thread 里取出来
为什么:smtplib 是同步栈,原先靠 asyncio.to_thread 卸载——线程池里躺着的仍是一次
完整的 TCP + TLS 握手加多轮 SMTP 交互,属于「同步网络 IO 藏在 to_thread 里」:既
占着线程,也不产生并发收益。这是本系列最后一条此类遗留(aria2p 那条已在 C2 清零)。
改动:
- auth_system/mail.py:_smtp_connect / _smtp_send 两个同步函数合成一个原生异步
_smtp_send,底层换 aiosmtplib.SMTP。TLS 口径与原实现一一对应:465 用 use_tls
(隐式 TLS),其余端口用 start_tls(显式 STARTTLS,先握手再 LOGIN);两条路都传
显式构造的 ssl.create_default_context(),不依赖运行时默认校验口径。finally 里
quit 失败只吞连接关闭错误,sendmail 的真实异常原样上抛——send_verify_code 依赖
它决定「不写库、不烧 24h 额度」。
- auth_system/mail.py:send_email 改直接 await;移除 import asyncio / import smtplib。
- auth_system/__init__.py:撤掉 _smtp_connect 的再导出——它是同步时代的辅助函数,
全仓无消费方,新实现里也不存在对应物。
- web/routers/register.py:更新过时的模块说明(原文仍写「smtplib 阻塞发送已卸载」)。
- pyproject.toml / uv.lock:新增 aiosmtplib>=5.1.3,<6.0。
- tests/test_mail_smtp.py(新增 5 条):SMTP 路径此前零覆盖,而「端口 → TLS 方式」
的映射属「授权码会不会明文过网」级别的差异,一旦被改反不会有任何地方报警。用假
SMTP 替身记录构造参数与调用序、全程不触网:587 必须 start_tls 且 LOGIN 在 connect
之后、465 必须 use_tls 且不得再叠 STARTTLS、端口缺省回落 587、quit 失败不掩盖结果、
sendmail 失败原样上抛且仍关连接。
验证:
- uv run ruff check . → All checks passed!
- uv run pyright → 0 errors, 0 warnings, 0 informations
(aiosmtplib 自带 py.typed,故对它的调用是有效类型校验;另用 inspect 逐一核对了
5.1.3 的 SMTP / connect / login / sendmail / quit 参数名)
- uv run pytest → 397 项,0 失败 / 0 error / 0 skipped(C3 后 392,新增 5)
- data/site.db sha256 前后一致(a19f5dbf…6487),无 -wal/-shm 残留
|
2026-09-29 15:28:13 +08:00 |
|
snowy-yang
|
e8cc7cb34a
|
refactor: 本地文件 IO 归位——重活留线程、微秒级直接同步,移除零使用依赖 aiofiles
为什么:文件 IO 这条线上 to_thread 用得过宽。凡是「单文件 exists / unlink / stat」
这类微秒级元数据操作,线程调度开销(几十微秒起)比操作本身还大,套 to_thread 只是
给同步调用加了层异步外壳,不产生任何并发收益;真正需要卸载的是整库复制、ZIP 整段
拷贝、SQLite 探测这类活。本批按这个口径把两端都摆正,并顺手修掉一处真实缺陷。
改动:
- web/routers/admin_site.py 备份上传:原 `content = await f.read()` 会把整库一次性
读进内存(端点对外无大小上限,等于给了上传方一个内存放大器),落盘后还要把文件
重新打开读一次头。改为 _save_upload_stream 按 1 MiB 逐块搬运——内存占用恒为一块,
文件头在写第一块时就地截获(省掉那趟回读)。仍属阻塞 IO,故保留在 to_thread 里。
- web/routers/admin_site.py backup_list:撤掉仅为一次 stat 而拆出的嵌套函数 +
to_thread,直接在事件循环里做完。
- web/routers/admin.py:save_cookie / logout / _get_cookie_info 四处 to_thread 改
直接调用——它们读写 1KB 级的 cookie.txt,全程不碰网络。
- music_api/qr_client.py:logout() 的 print 改 logger。该方法既被 CLI 也被 admin
端点调用,在 web 语境里 print 会把内容直接喷到服务端 stdout,绕过 logging 配置、
无法被收集,也污染容器日志。
- auth_system/backup.py:三处单文件 unlink(占位文件回滚、删备份、裁剪 pre_restore_)
改直接同步;把只为配合 to_thread 而拆出的 _backup_path_sync 并回 backup_path。
保留在线程里的:整库 copy2 / os.replace、glob + N 次 stat、SQLite 探测、以及持
跨线程锁的 _reserve_backup_path(内联会让事件循环等一把别的线程可能持有的锁)。
- web/routers/delivery.py:两处 os.path.exists 改直接同步(带 ASYNC240 行内豁免,
与 core/db.py、auth_system/db.py 同一写法),顺带删掉随之未使用的 asyncio 导入。
- pyproject.toml / uv.lock:移除 aiofiles。它全仓零引用,且底层同样是 to_thread——
对 1KB 的 cookie 文件不会带来任何真异步收益。
验证:
- uv run ruff check . → All checks passed!
- uv run pyright → 0 errors, 0 warnings, 0 informations
- uv run pytest → 392 项,0 失败 / 0 error / 0 skipped
(含 test_admin_backup_upload 四条:接受真实 SQLite / 缺字段拒 / 后缀拒 /
后缀对但内容非库必须拒且不留半成品)
- data/site.db sha256 前后一致(a19f5dbf…6487),无 -wal/-shm 残留
|
2026-09-29 15:20:51 +08:00 |
|
snowy-yang
|
046225e38d
|
refactor: aria2 改手写原生异步 JSON-RPC,aria2p 与同步 HTTP 栈从依赖树清零
为什么:aria2p 内部是 requests + websocket-client 的同步实现,每个 RPC 都是一次
「同步网络 IO 藏在 to_thread 里」——线程池跑得再满也不产生真正的并发,每次轮询
还要额外付一次线程跳转。这是本项目最后一处此类残留,删掉后整个进程的网络 IO
都走同一个 aiohttp 连接池。
改动:
- music_downloader/_aria2.py 整体重写:新增 _Aria2Rpc,直接向 aria2 的
JSON-RPC 2.0 端点发 getVersion / addUri / tellStatus / forceRemove /
removeDownloadResult,一律走 core.http_client.request_once(不重试,与 aria2p
口径一致);新增 Aria2RpcError 承载 HTTP 200 + error 对象的协议错误。
- _abort 逐条复刻 aria2p.remove(force=True, files=?, clean=True) 语义:终态走
removeDownloadResult;在途先 forceRemove 再清结果表;控制文件 .aria2 无条件
删除(clean=True 默认);仅当 files 且主移除成功才删数据文件。
- get_backend / _try_start 改原生异步:asyncio.Lock 单飞 + await rpc.get_version()
探活,不再用 to_thread 包 subprocess.Popen;超时路径改 to_thread(_reap) 回收。
- 新增 Protocol _Rpc / _ProcessHandle(补 returncode 声明)——只声明本模块真正
调用的方法,测试据此用轻量替身驱动状态机,不必依赖真实 aria2 进程。
- pyproject.toml 删 aria2p 依赖并同步更新过时注释;uv lock 连带移除 8 个传递
依赖:requests / urllib3 / websocket-client / charset-normalizer / loguru /
platformdirs / win32-setctime。至此全仓无任何 requests 栈。
- tests/test_aria2.py 重写为 21 项用例(_FakeRpc / _FakeProc / _LiveProc /
_FakeHTTP 替身),新增终态体积守卫、_try_start 三分支(就绪重试 / 超时不就绪
杀进程 / 进程提前退出)与 JSON-RPC 信封四例。
- tests/test_downloader.py 的 get_backend 替身由同步 lambda 改为协程——调用点
已直接 await,同步替身会被 await 成 None 而报 TypeError。
验证(在 aria2p 已物理移除的环境上复核,故能真正证明无残留依赖):
- uv run ruff check . → All checks passed!
- uv run pyright → 0 errors, 0 warnings, 0 informations
- uv run pytest → 392 项,0 失败 / 0 error / 0 skipped(基线 382,净增 10)
- data/site.db sha256 前后一致(a19f5dbf…6487),无 -wal/-shm 残留
|
2026-09-29 15:14:56 +08:00 |
|
snowy-yang
|
133beab492
|
chore: 删除 HTTP 线三个垫片,同步/异步桥接层彻底清零
删 core/sync_http.py(requests 同形同步门面)、core/async_bridge.py(run_sync
同步↔异步桥)、core/http_errors.py(两垫片共用的 requests 同形异常层次),
以及 tests/test_async_bridge.py。
为什么必须一次删完:三者是**自闭环三角**——sync_http 是 async_bridge 与
http_errors 的唯一消费者,删掉任何一个其余两个立刻悬空。前序 B1 把 worker 变成
协程、B2 把 auth_system 迁到 aiosqlite、更早一轮把 music_api 改走
core.http_client 之后,这条链上的生产消费者已归零;http_errors 更是在 B2 之后
只剩 sync_http 一个使用者(此前 sync_http 的 docstring 还提到
models._RetryFromScratch 继承它的 RequestException——B1 已改为继承 Exception)。
删除前的核查(避免误删活代码):
- run_sync / get_loop / SyncResponse / Session( 全仓零残留引用(只剩
aiohttp.ClientSession,那是真正的客户端,不受影响)。
- 所有 HTTPError 命中都是 core.http_client.UpstreamHTTPError——与
core.http_errors 的 HTTPError **名字像但不是一个东西**,删除时别被 grep 骗。
- requests 早已不是声明依赖、全仓零导入;删掉 http_errors 后连 requests 同形
异常的最后残影也没了。
- core/__init__.py 只有 docstring,无需改动;web/app.py 的 async_bridge.shutdown()
已在 B1 移除。
顺带修正两处过时措辞:tests/test_search_music.py 的错误映射注释
RequestException → UpstreamError(异常层次已换);pyproject.toml 中 pyright
include 的理由注释 requests→sync_http → requests→aiohttp(去掉已删模块名、
保留原意)。auth_system/geo.py 的历史注释本就是过去时,保持不动。
门禁:ruff 全过 / pyright 0-0-0 / pytest 382 项全绿(0 失败 0 error 0 skipped)。
项数 384→382 是 test_async_bridge.py 两条用例**随被测对象一起删除**的预期减项,
不是跳过或漏跑。data/site.db 读写前后 sha256 一致(a19f5db…6487)。
至此「同步调异步 / 异步桥同步」两类垫片全部拆净:DB 线(B1 worker + B2
auth_system)、HTTP 线(本批)。生产代码里 to_thread 仅剩正当的阻塞卸载
(PBKDF2 / smtplib / 备份文件 IO / ZIP 组装 / rmtree),threading.Event 仅剩
取消与暂停的跨线程置位载体。
|
2026-09-28 20:18:08 +08:00 |
|
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 |
|
snowy-yang
|
65adfc9089
|
refactor: 删除 aiohttp_compat 垫片,music_api 改走原生 core.http_client
core/aiohttp_compat 是「同步转异步」迁移期的 requests 同形异步垫片:它存在的
唯一理由是让既有 `requests.post/get + raise_for_status()` 风格的代码以最小
改动迁到异步。其两个调用方(music_api/api.py、music_api/client.py)**本就已是
async def**——垫片只剩「保留 requests 语义」这一层包装,属于迁移残留。
改动:
- api.py:23 处 requests.post/get → await core.http_client.request()。
该文件每处都在请求后立刻 raise_for_status() 才取 .json(),从不把 4xx 当数据读,
故「_once 对非 2xx 直接抛 UpstreamHTTPError」与旧语义等价(原 raise_for_status
抛的 HTTPError 也一律落入 RequestException 分支);
except requests.RequestException → except UpstreamError。
- client.py:两个自管重试的方法改用新公开的 core.http_client.request_once()
(单次、不重试),保留原有「5xx 退避重试一次 / 4xx 立即失败」的分流——
把 status_code>=500 的判断换成 UpstreamHTTPError.status,行为逐条对齐
(4xx=风控不重试,网络/5xx 重试一次)。响应体上限交由客户端 max_bytes 统一把关。
post_request_full 返回类型改为 HTTPResult(qr.py 用到的 .text/.cookies/.headers
三者在 HTTPResult 上齐备)。
- core/http_client:_once 更名并公开为 request_once(自管重试的调用方需要它)。
注:这是为「删垫片」而补的唯一一个小口子,不是新增抽象。
注意一处**有意的行为变化**:api.py 原经垫片请求零重试,现在 request() 自带
「网络异常/5xx 退避重试一次、4xx 不重试」(core.http_client 既定口径)。
429/460 属 4xx 不重试,不加剧风控。
- 删除 core/aiohttp_compat.py;web/app.py 去掉其 close();pyproject 去掉其
per-file-ignore;core/http_errors.py 注释更正为「仅供 core.sync_http」。
- 测试:test_aiohttp_compat.py 拆解为 test_async_bridge.py(只留桥的两条契约,
垫片自身的 4 条随垫片消亡);test_search_music / test_playlist_detail 的替身
边界从 api.requests.post 换成 api._http.request,异常类型换成 UpstreamNetworkError。
门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败;383=387-6+2,账面吻合)。
|
2026-09-28 19:17:03 +08:00 |
|
snowy-yang
|
064a89d726
|
refactor: 删除旧前端单体,React 产物成为唯一首页来源
为什么:React 迁移已验证完毕(浏览器冒烟全过、golden 刷新),
legacy 模板兜底分支指向已删除的文件只会制造幻觉路径。删除
templates/index.html(466KB)与 static/site_auth.js(80KB)及
/static 挂载;jinja2 随兜底分支失去唯一使用者,连同 markupsafe
一并移出依赖(63 包 -2)。_render_index 简化为直接读构建产物——
dist 已入库恒存在,无需分支。
|
2026-09-27 18:41:30 +08:00 |
|
snowy-yang
|
8803444755
|
feat: 后端接线上 React 构建产物,legacy 模板降级为兜底
为什么:前端迁移到 Vite 后,首页应直接返回 frontend/dist/index.html
字节(golden 口径随之变为构建产物原样字节),仅当 dist 未构建时
回退 Jinja2 渲染的 legacy 模板。同步挂载 /assets 静态目录、排除
frontend 的 node_modules/tsbuildinfo 入库、docker 构建只带 dist
不带源码、ruff/pyright 排除 frontend 目录(TS 侧由 tsc+vite 把关)。
|
2026-09-27 18:35:04 +08:00 |
|
snowy-yang
|
095171e0b7
|
refactor: 两个根级游离模块并入 music_api 包,消除分层倒置
为什么:podcast_api.py(560 行) 与 qr_login.py(397 行) 既不属于任何包、也不是
入口脚本,却同时被 services 层与 web 层引用——`services/downloads.py` 直接
`from qr_login import ...`、`services/podcast.py` 直接 `import podcast_api`,
业务层反向依赖顶层游离模块,是本次架构体检里唯一的无争议分层倒置。
两者本就依赖 music_api(前者取 url_v1/APIConstants/APIException,后者取
QRLoginManager),与 music_api 下已有的 qr.py / compat.py 是同一层的同类物。
迁移(git 保留重命名历史):
- podcast_api.py -> music_api/podcast.py
- qr_login.py -> music_api/qr_client.py
**命名避让**:不叫 music_api/qr_login.py——music_api/__init__ 已经从 .compat
导出了一个**同名函数** qr_login,同名子模块会与之互相遮蔽。改叫 qr_client
(其核心导出即 QRLoginClient),并在验证里专门确认 music_api.qr_login 仍是
可调用函数、music_api.podcast 是模块,两者不冲突。
内部导入顺带改为精确子模块,不再绕道包门面:
- podcast.py:from music_api import APIConstants/APIException/url_v1
→ from .compat import url_v1 / from .constants import ... / from .errors import ...
- qr_client.py:删掉那层 try/except ImportError 包装(它给的是「请确保
music_api.py 文件存在」这类顶层脚本时代的提示,作为包内模块已无意义),
改 from .cookies/.errors/.qr 相对导入
- CLI 用法文本 python qr_login.py → python -m music_api.qr_client
(相对导入后直接跑脚本会失败,必须走 -m;全仓确认无脚本/文档引用旧入口)
- 路径安全性已核对:qr_client 用的是 CWD 相对的 cookie.txt,不是 __file__ 相对,
迁移不改其解析结果
引用点全部更新(AST 扫描确认覆盖全集,替换后残留 0):
- podcast:services/podcast.py(1 + 5 处调用) · web/routers/podcast.py(1 + 6)
· web/routers/download.py(2 处函数内 + 3),导入名统一为 `podcast`
- qr_login:services/downloads.py · web/routers/admin.py · web/routers/user.py(×2)
- services/__init__.py 依赖自检列表去掉这两个名字(已并入 music_api)
- pyproject include 去掉两个顶层文件名(music_api 目录已覆盖)
验证:
- 11 + 3 个符号在新位置全部 hasattr 通过;podcast_api / qr_login 已
ModuleNotFoundError;6 个调用方模块均可 import;services 依赖自检仍生效
- 门禁:ruff 0 · pyright 0 errors/0 warnings · pytest 184 passed
- 真实 uvicorn 冒烟 10/10,含 /podcast/parse 与 /podcast/hot 返回 401
(模块导入失败会是 500,路由缺失会是 404,401 证明两者都正常)
- 生产 data/ 指纹 before == after
|
2026-09-26 15:03:47 +08:00 |
|
snowy-yang
|
ad8c30d932
|
refactor: 下载/标签/格式探测改用成熟库(aria2 + mutagen + filetype)
为什么:原先三处都在手写轮子——ffmpeg CLI 拼参数写标签、aiohttp 单连接手搓
下载、自己比对魔数判格式。手写部分既难测又易错;改走成熟库后测试从 144 涨到
175,并且彻底去掉了 ffmpeg 这个外部二进制依赖。
下载 → aria2c 常驻 JSON-RPC(aria2p 客户端),新增 _aria2.py
- 单例后端:首次调用时拉起 aria2c,只监听 127.0.0.1 且强制随机 secret
(无鉴权的 aria2 RPC 允许本机任意进程操纵队列、读写任意文件)
- split=5 / max-connection-per-server=5 多连接 + 原生断点续传
- 异常语义与内置通道逐一对齐:取消/超限清残片抛 DownloadCanceled /
DownloadException,任务失败/超时保留残片抛 RequestException 供续传
- 取不到 aria2c 时 get_backend() 返回 None,自动降级到内置 aiohttp 通道
(本机无 aria2c 的降级路径已实测)
标签 → mutagen 纯 Python 写入,新增 tagging.py
- 取代 _write_metadata_with_ffmpeg + _build_format_args(226 行 shell 参数拼接)
- MP3(ID3v2.3)/FLAC/M4A/OGG/Opus 分容器实现,无子进程、不重编码
- 就地改帧:TXXX 等未触及的标签原样保留(ffmpeg 整帧重写会丢)
- 临时文件 + os.replace 原子替换,写坏不会留下半个文件
- 为什么不是 ffmpeg-python(原方案):实测它用 **kwargs 建模选项,无法表达
重复键(-metadata ×N),list 值还会被字符串化成 "['0', '1:v']";-map 尚有
内置的 stream 位置参数入口,但 -metadata 无解,且 overwrite_output=True
生成的 -y 被排到输出文件之后、可能失效
格式探测 → filetype,改 _http.py
- 删除手写的 _AUDIO_MAGIC 魔数表,改用 filetype.guess() 判 MIME
(audio/* 放行,video/mp4 作为容器显式放行)
配套
- Dockerfile:镜像不再装 ffmpeg,改装 aria2
- pyproject:新增 filetype / aria2p / mutagen;dev 加 httpx2(消除测试里的
StarletteDeprecationWarning,现 0 warning)
- __main__.py / __init__.py / models.py:清理 ffmpeg 相关导出与文案
测试:新增 31 个用例(test_aria2.py 13、test_tagging.py 21,含最小合法 FLAC
样本构造);全套 175 passed / 0 warning
|
2026-09-26 13:54:44 +08:00 |
|
snowy-yang
|
183f5744d8
|
fix: 修复 7 处变量可能未绑定的真缺陷,类型检查升到 standard
为什么:IDE 里成片的黄色波浪线并非风格噪声。ruff 与 pyright(basic) 都是
0 报错,但 basic 会整体漏报「变量可能未绑定」这类**可执行缺陷**;实测
standard 多报 7 处且全部为真——异常处理器引用了尚未赋值的局部变量,一旦走进
异常分支就会 NameError 二次崩溃,把原始异常彻底掩盖。
- core/db.py, core/http_client.py:3 处 @asynccontextmanager 的返回标注
AsyncIterator[X] 更正为 AsyncGenerator[X, None](前者不含 send 语义)
- core/state.py:三个模块级缓存补 TypedDict 建模。原先无标注的 dict 字面量被
推断为「所有值类型的并集」(float | None),每个读写点都退化成联合类型;
拆成 _VipCache(data 可为 None 表示未探测)与 _VipEntry(data 必定存在)后,
类型检查立刻抓出 account.py 对未探测缓存的直接解包隐患
- services/batch.py:zip_valid 提到函数头。ZIP 构建前的业务错误(参数缺失/
无歌曲)会 early raise,而异常处理器引用了该名字 → NameError 二次崩溃
- services/podcast.py:radio_name、pct 同理补初始化
- services/downloads.py:声明返回 str 却可能给出 None,改 `or ''` 满足契约
- web/routers/download.py:id_list 提前绑定
- web/auth_state.py:解包出的 c 未被使用
- pyproject.toml:typeCheckingMode 由 basic 升到 standard 作为门禁,并移除
已被 standard 覆盖的 reportGeneralTypeIssues
门禁:ruff All checks passed · pyright(standard) 0 errors 0 warnings
|
2026-09-26 13:54:23 +08:00 |
|
snowy-yang
|
6288a98ecc
|
refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留
大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述,
会误导后来者按不复存在的架构去改代码。
拆分(对外导入路径不变,行为零变更):
- music_downloader.py -> music_downloader/(models/downloader/cover/_http/_log/_concurrency)
- auth_system.py -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...)
- music_api.py -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors)
- services/runtime.py -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/
streaming/tasks);services/runtime.py 保留为兼容门面
兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点
(auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性
动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。
修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字
等价」校验逐一定位,静态检查只会报出其中 4 处):
- core/config.py ).strip() or '*'
- core/state.py threading.Lock() x2
- qr_login.py datetime.now() / .strip()
- music_downloader/downloader.py .lower()
- services/podcast.py set() / time.time() x2
清理与删除:
- 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt
- 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等
失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明)
- _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含
- pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败
验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed;
真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
|
2026-09-26 12:37:48 +08:00 |
|
snowy-yang
|
838d635bc5
|
fix: 修复重构遗留缺陷,补齐 pyright 检查盲区
上一次重构(8446166)声称「pyright 0 错误」,但 pyproject 里 pyright 的 include
只覆盖 core/web/services —— 根目录 6 个应用模块与整个 tests/ 从未被检查,而
requests→sync_http 迁移的遗留恰好全部藏在那里。补齐 include 后立刻暴露
7 errors + 9 warnings,其中数处在运行时静默失效。
修复:
- services/runtime.py:删除 _podcast_billing_count/_billing_ids_count 两个
无调用方的死函数。它们仍调用 api_service._safe_get_request_data(),而该方法
在剥离 Flask 时已被删除,异常又被 except 吞掉 —— 一旦被调用,播客/歌单计费
恒为 1 次且不报任何错。路由层已各自实现同口径版本,故整体删除。
- web/routers/admin_site.py:备份上传的类型判定必须用 starlette 的 UploadFile。
fastapi.UploadFile 是它的子类,而 request.form() 返回基类实例,用错会让
isinstance 恒为 False,把每一次真实上传都判成「请选择数据库文件」。
已补 4 例真实 multipart 回归测试,并用变异测试确认能抓住该缺陷。
- auth_system.py:修正 create_user(实返 sqlite3.Row|None)与 list_cards
(实返 (cards,total) 元组)与实现不符的返回注解;注解一改正,立刻暴露出被它
掩盖的 register.py 中 int(user['id']) 未做 None 检查的 500 崩溃路径,已补守卫。
- music_downloader.py:修正注解引用的 core.sync_http.Response(正确名应为
SyncResponse);封面缓存淘汰不再读类型为 Optional 的 deque.maxlen,改用命名常量。
- services/runtime.py:zip worker 的 result.music_info 可能为 None,原样使用会
炸掉整批任务,改为按单曲失败处理。
- music_api.py:三处 isinstance 三元表达式收敛为 _as_dict() 帮助函数。
- web/main.py:文档串移除已删除的 Flask 兜底描述,补充 data/ 相对 CWD 解析的提醒。
验证:ruff check 通过;pyright 0 errors / 0 warnings(filesAnalyzed=67);
pytest 144 passed;真实 uvicorn 启动冒烟通过(/health、/、静态资源 200,
未鉴权管理端点 403)。生产库仅做只读 integrity_check,未被写入。
|
2026-09-26 11:52:06 +08:00 |
|
snowy-yang
|
8446166120
|
refactor!: uv 管理环境 + 彻底删除技术债(Flask 与 requests 全部移除)
## uv 环境管理
- pyproject.toml 声明 dependencies + [dependency-groups].dev,uv.lock 精确锁定
- 删除 requirements.txt / requirements-fastapi.txt / requirements-dev.txt
- .python-version = 3.12;Dockerfile 改用 uv sync --frozen(可复现构建)
- 新增 jinja2(首页渲染)
## 删除 Flask 技术债
- 从 main.py 用 ast 精确剥离 56 个 Flask 节点(路由/中间件/错误处理/启动),
框架无关的服务层落为 services/runtime.py(MusicAPIService、下载/打包 worker、
任务监控、缓存清扫),零 Flask 依赖
- 删除 main.py(4527 行)与 auth_routes.py(1050 行)
- auth_system.py 移除会话/请求上下文层(login_session/current_user/login_required/
admin_required/_client_ip 等),保留数据与业务层
- 新增 core/net.py(IP 解析)、web/auth_state.py(登录锁定/2FA 票据/发码限流)
- FastAPI 侧改直接依赖 services.runtime,不再 import main
- web/app.py 去掉 Flask fallback;入口默认根路径模式(API_PREFIX 可选)
## 删除 requests 技术债
- 新增 core/sync_http.py:requests 风格的同步门面(底层 aiohttp 连接池,
流式读取经有界队列背压),含 Session/headers/(connect,read) 元组超时兼容
- core/utils、auth_system、podcast_api、music_downloader、services 全部切换
- 实测真实下载 5.2MB 音频完整成功
## 质量门禁归零
- ruff:0 错误(B904 全部修复,不再忽略)
- pyright:0 错误(类型收窄修复)
- pytest:140/140(含 golden 契约回归、root 模式 66 项路径覆盖)
- 真实 uvicorn 启动:/health、/、/static、受保护端鉴权全部正确
新增 tests/golden/responses.json:删除 Flask 前捕获的 32 例响应快照,
FastAPI 逐字段复现——删除巨石后行为回归仍有硬证据。
|
2026-09-26 11:10:17 +08:00 |
|
snowy-yang
|
ce7ea66d6a
|
refactor(async): music_api 异步化 + 全项目 lint 收敛
music_api.py:
- 22 个方法改 async(HTTPClient.post_request*/NeteaseAPI.*/QRLoginManager.*),
33 处 requests.* → await core.aiohttp_compat.*
- 16 个模块级同步包装改走 run_sync 桥(main.py 等 99 个同步调用点零改动)
- async 内 time.sleep → asyncio.sleep(修 7 处阻塞事件循环)
musica_downloader/qr_login:
- 单曲三请求 ThreadPoolExecutor → asyncio.gather(语义等价,失败处理保留)
- interactive_login 改 async,CLI 入口用 asyncio.run
真实缺陷修复(lint 发现):
- podcast_api._to_int 未定义(2 处调用会 NameError)→ 补安全转换 helper
- main.py VALID_QUALITY_LEVELS 重复定义(覆盖 core.config 导入值)→ 移除
- 移除死代码变量(salt×2/device_id×3/file_path/zipped_files)
lint 收敛:524→0(ruff All checks passed);pyright 0 errors。
pyproject 记录「有意保留」项(B904/RUF012/S305 上游协议等)及理由。
验证:冒烟 10/10;pytest 16/16;真实上游 get_toplist 异步直调与桥接均得 63 条。
|
2026-09-26 00:32:45 +08:00 |
|
snowy-yang
|
4962542e78
|
feat(core): requests 兼容异步垫片 + 同步/异步桥
- core/aiohttp_compat.py:以 requests 同形 API(post/get + Response.json/text/
status_code/raise_for_status + HTTPError/RequestException/Timeout)实现异步请求,
使既有 requests 风格代码可「加 await + async def」最小改动迁移;4xx/5xx 不抛、
仅 raise_for_status() 抛(与 requests 语义一致,保住风控判断逻辑)
- core/async_bridge.py:run_sync(coro) 经常驻后台事件循环线程执行协程并阻塞等待,
供迁移期同步调用方(遗留 Flask 路由/线程池)复用异步实现
验证:pytest 16/16;ruff All checks passed;pyright 0 errors。
|
2026-09-26 00:27:47 +08:00 |
|
snowy-yang
|
9f56f68b66
|
chore(async-infra): 异步基础层 + 工具链 + 清理
异步基础层(core/):
- db.py aiosqlite + WAL:读路径无锁并发、写路径 asyncio.Lock 串行,
busy_timeout 自旋——实现「读写同时进行」(旧实现用 RLock 全读串行)
- http_client.py aiohttp 连接池 + 有界重试(5xx 重试 / 4xx 不重试)+
响应体上限 + 流式接口;按事件循环隔离 session(支持迁移期双 loop)
工具链:
- pyproject.toml ruff(E/F/W/I/B/UP/C4/ASYNC/S/RUF) + pyright(basic) + pytest(asyncio auto)
- requirements-fastapi.txt FastAPI/aiohttp/aiosqlite/aiofiles 等目标栈
- requirements-dev.txt 开发依赖(pytest/ruff/pyright)
清理:删除无用脚本 _csscheck.py 与产物标记 _css_ok.txt/_pkg_ok.txt,同步 .gitignore
验证:pytest 10/10(含「写 200 条同时 4 读协程零异常」并发用例);
ruff All checks passed;pyright 0 errors。
|
2026-09-26 00:24:00 +08:00 |
|