Archived
为什么:原先三处都在手写轮子——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
87 lines
1.8 KiB
Python
87 lines
1.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""数据模型、异常与格式常量。
|
||
|
||
(自 music_downloader.py 拆出,行为与原实现一致。)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from enum import Enum
|
||
|
||
from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests)
|
||
|
||
# ============ 类型定义 ============
|
||
|
||
class QualityLevel(Enum):
|
||
"""音质等级枚举"""
|
||
STANDARD = "standard"
|
||
EXHIGH = "exhigh"
|
||
LOSSLESS = "lossless"
|
||
HIRES = "hires"
|
||
SKY = "sky"
|
||
JYEFFECT = "jyeffect"
|
||
JYMASTER = "jymaster"
|
||
DOLBY = "dolby"
|
||
|
||
|
||
@dataclass
|
||
class MusicInfo:
|
||
"""音乐信息数据类"""
|
||
id: int
|
||
name: str
|
||
artists: str
|
||
album: str
|
||
pic_url: str
|
||
duration: int
|
||
track_number: int
|
||
download_url: str
|
||
file_type: str
|
||
file_size: int
|
||
quality: str
|
||
lyric: str = ""
|
||
tlyric: str = ""
|
||
publish_year: int | None = None
|
||
|
||
|
||
@dataclass
|
||
class DownloadResult:
|
||
"""下载结果数据类"""
|
||
success: bool
|
||
file_path: str | None = None
|
||
file_size: int = 0
|
||
error_message: str = ""
|
||
music_info: MusicInfo | None = None
|
||
|
||
|
||
class DownloadException(Exception):
|
||
"""下载异常类"""
|
||
pass
|
||
|
||
|
||
class DownloadCanceled(DownloadException):
|
||
"""下载被用户取消"""
|
||
pass
|
||
|
||
|
||
class _RetryFromScratch(requests.RequestException):
|
||
"""内部信号:断点续传起点失效(HTTP 416 等),清除 .part 后从头重试"""
|
||
pass
|
||
|
||
|
||
# ============ 工具映射表 ============
|
||
|
||
# API 返回的 type -> 文件扩展名
|
||
API_TYPE_TO_EXT = {
|
||
"mp3": ".mp3",
|
||
"flac": ".flac",
|
||
"m4a": ".m4a",
|
||
"mp4": ".mp4",
|
||
"ogg": ".ogg",
|
||
"opus": ".opus",
|
||
}
|
||
|
||
|
||
# 支持元数据写入的扩展名(tagging.py 按此表分派到各容器的标签写入器)
|
||
METADATA_FORMATS = {".mp3", ".flac", ".m4a", ".mp4", ".ogg", ".opus"}
|