Commit Graph
3 Commits
Author SHA1 Message Date
snowy-yang 8716617d5d refactor: download_music_file 拆解(205 行 / 圈 47 → 125 行 / 圈 20)
为什么:download_music_file 是**唯一**的音频落盘路径(单曲 worker、音乐批量 ZIP
都经它),205 行 / 圈复杂度 47 / 嵌套 7 的单体里叠着缓存校验、断点续传、内容嗅探、
体积上限、取消语义、重试退避、元数据限流七件事,却**零直接测试覆盖**
(test_zip_workers 里只有一个 stub)。任何一处被改坏都不会有测试报警。

改法:拆成 8 个具名方法,主函数只留「5 步管线 + 重试策略」:
  _cached_result          锁内复查缓存(命中即返回,坏缓存删除重下)
  _promote_complete_part  已下完未改名的 .part 直接转正,不发起网络请求
  _resume_offset          断点续传起点,并清理没有续传价值的残片
  _transfer               占全局名额 + 选通道(aria2c / 内置 aiohttp)
  _size_mismatch_error    完整性判定(已知期望精确相等 / 未知期望至少非空)
  _publish                嗅探 → 原子转正 → 写元数据
  _write_metadata_locked  标签信号量内的元数据写入(原三处重复的 with 收敛)
  _backoff_wait           退避秒数(原五处重复的 0.5*(attempt+1) 收敛)

复杂度(同口径 AST 实测):
  download_music_file  205 行 / 圈 47 / 嵌套 7  →  125 行 / 圈 20 / 嵌套 4
                       (125 行里 26 行是 docstring,正文 87 行)
  文件内嵌套 >=5 层     2 → 1(余下的是本次未动的 _fetch_via_http)
  文件内最大圈复杂度     47 → 35(同样落在未动的 _fetch_via_http 上)
  文件行数             614 → 710(+96,全是新签名与 docstring)

等价性证据(两道,互相独立):
1. 29 例行为契约测试**在未改动的原始实现上一次性全绿**(先写测试、后动代码;改完
   再跑仍全绿)——这批测试因此忠实编码了旧行为,构成等价性验证,而不是「照着新
   实现写的新行为测试」。断言只依赖返回值 / 磁盘上的文件 / 发往网络的请求,不引用
   任何内部函数名,故可跨后续重构复用。
2. AST 字面量多重集比对 + **逐条位置定位**:数值/布尔 27 种、字符串 80 种。差异
   全部归因,且**没有一条用户可见文案或阈值被改动**——
     0   +2:_resume_offset 的三个 return 0 取代原 resume_from = 0
     0.0 +1、0.5 −3、1 −3:四处重复的 0.5*(attempt+1) 收敛进 _backoff_wait
     None +16:新增方法的 6 处类型标注 + 4 处 return None + 2 处 is not None
     ''  +1:_size_mismatch_error 的内部哨兵(非用户可见)
   续传起点逻辑另做逐例核对:promote 命中 / existing<=0 / 超尺寸 / 部分 四路等价。

刻意保留的语义(是决定,不是疏漏):
- 退避睡眠仍在文件锁外执行——同名并发任务不陪睡。
- 缓存命中时若 write_meta 仍按本次请求重跑一次标签写入(幂等);write_meta=False
  则不写。
- 已转正(os.replace 之后)才发现取消时**不回滚文件**,只跳过元数据——原行为如此,
  已用 test_cancel_after_promote_keeps_file_but_skips_metadata 固化。
- 未知期望尺寸(file_size=0)走「非空即通过」+ 内容嗅探兜底;已知期望尺寸要求
  **精确相等**(放宽到容差会交付截断的坏文件还照扣次数)。
- 缓存有效性用 st_size != file_size(而非 >=):跨账号音质降级 / 上游变更都会让
  同 ID 同音质的实际文件不同,命中即返回会把旧文件当本次结果交付。

测试:新增 tests/test_downloader.py(29 例)——正常落盘与 .part 转正、空分块跳过、
进度回调(起点 / 每块 / 异常吞掉)、缓存命中不发请求、坏缓存重下、0 字节缓存拒绝、
续传发 Range 并追加、服务器忽略 Range 时重写、完整残片免网络转正、超尺寸残片丢弃、
206 起点错位 / 206 缺 Content-Range / 416 三路从头重下、CDN 4xx 不重试、408 可重试、
不完整保留 .part、下轮续传、未知尺寸非音频拒绝、体积上限、五种取消路径、元数据开关。

验证:ruff 全过 · pyright 0/0 · pytest 274 passed(245→274)· 生产 data/ 未被触碰
(全部用例走 tmp_path 沙箱)。
2026-09-27 07:56:01 +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 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