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
This commit is contained in:
snowy-yang committed 2026-09-26 13:54:44 +08:00
1 parent 183f5744d8
commit ad8c30d932
12 files changed
+1367 -369

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
"""aria2 下载后端的单元测试(用假 api 驱动,不需要真实 aria2c 二进制)。
覆盖:
- aria2c 缺失时 get_backend 静默降级且失败结果被记忆
- 正常完成路径的进度回调与自动清理
- 取消 / 体积超限 连带删除残片;任务失败 / 总超时 保留残片供续传
- 提交失败与 RPC 抖动统一上报为可重试异常
"""
from __future__ import annotations
import threading
import pytest
import music_downloader._aria2 as a2
from core.sync_http import RequestException
from music_downloader import DownloadCanceled, DownloadException
class _FakeDownload:
"""按预设步进产出状态,模拟 aria2 的 tellStatus 轮询。"""
def __init__(self, steps):
self.gid = 'g1'
self._steps = list(steps)
self._i = 0
self.completed_length = 0
self.total_length = 100
self.is_complete = False
self.has_failed = False
self.error_message = ''
self.status = 'active'
def update(self):
idx = min(self._i, len(self._steps) - 1)
done, complete, failed = self._steps[idx]
self._i += 1
self.completed_length = done
self.is_complete = complete
self.has_failed = failed
if failed:
self.status = 'error'
self.error_message = 'boom'
class _FakeAPI:
def __init__(self, download, fail_submit=False, fail_update=False):
self.download = download
self.removed: list[tuple[str, bool, bool]] = []
self.submitted: tuple[list[str], dict[str, str]] | None = None
self._fail_submit = fail_submit
self._fail_update = fail_update
def add_uris(self, uris, options=None):
if self._fail_submit:
raise RuntimeError('rpc down')
self.submitted = (list(uris), options or {})
return self.download
def remove(self, downloads, force=False, files=False):
self.removed.append((downloads[0].gid, force, files))
class _FailUpdateDownload(_FakeDownload):
def update(self):
raise RuntimeError('rpc jitter')
class _FakeProc:
def poll(self):
return 0
def terminate(self):
pass
def kill(self):
pass
def wait(self, timeout=None):
return 0
def _backend(monkeypatch, steps, **kw):
monkeypatch.setattr(a2.time, 'sleep', lambda _s: None)
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
# ---------- 降级 ----------
def test_get_backend_degrades_without_binary(monkeypatch):
monkeypatch.setattr(a2.shutil, 'which', lambda _n: None)
monkeypatch.setattr(a2, '_backend', None)
monkeypatch.setattr(a2, '_unavailable', False)
assert a2.get_backend() is None
assert a2.get_backend() is None # 失败结果被记忆,不反复拉起进程
def test_free_port_is_in_valid_range():
assert 0 < a2._free_port() < 65536
# ---------- 完成路径 ----------
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)))
assert seen == [(50, 100), (100, 100)]
assert api.removed == [] # 完成不需要中止
assert not (tmp_path / 'y.part.aria2').exists() # 控制文件已兜底清理
submitted = api.submitted
assert submitted is not None
options = submitted[1]
assert options['continue'] == 'true' # 续传选项已下发
assert options['max-connection-per-server'] == str(a2._SPLIT)
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) # 不应抛出
# ---------- 取消 / 守卫 / 失败 ----------
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)
assert api.removed == [('g1', True, True)]
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)
assert api.removed == [('g1', True, True)]
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')
assert api.removed == [] # 残片保留,供下轮续传
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)
assert api.removed == [('g1', True, False)] # 超时中止但保留残片
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')
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')
def test_close_is_idempotent(monkeypatch):
be, _api = _backend(monkeypatch, [])
be.close()
be.close()