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
85 lines
2.8 KiB
Python
85 lines
2.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""命令行入口(python -m music_downloader)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from .downloader import (
|
||
MusicDownloader,
|
||
)
|
||
from .models import (
|
||
DownloadException,
|
||
)
|
||
|
||
# ============ CLI 入口 ============
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
from dataclasses import asdict
|
||
|
||
downloader = MusicDownloader()
|
||
|
||
# 无参数:打印模块信息
|
||
if len(sys.argv) < 2:
|
||
print("网易云音乐下载器")
|
||
print("支持的音质: standard / exhigh / lossless / hires / sky / jyeffect / jymaster / dolby")
|
||
print("支持的功能: 同步/批量下载、标签与封面写入(mutagen 纯 Python)")
|
||
print()
|
||
print("用法:")
|
||
print(" python music_downloader.py <music_id> [quality] # 仅打印信息")
|
||
print(" python music_downloader.py <music_id> [quality] --download # 打印并下载")
|
||
print()
|
||
print("示例:")
|
||
print(" python music_downloader.py 123456")
|
||
print(" python music_downloader.py 123456 lossless")
|
||
print(" python music_downloader.py 123456 hires --download")
|
||
sys.exit(0)
|
||
|
||
# 解析参数
|
||
try:
|
||
music_id = int(sys.argv[1])
|
||
except ValueError:
|
||
print(f"错误: music_id 必须是整数,收到: {sys.argv[1]!r}")
|
||
sys.exit(1)
|
||
|
||
quality = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith("--") else "lossless"
|
||
do_download = "--download" in sys.argv
|
||
|
||
# 打印音乐信息
|
||
try:
|
||
info = downloader.get_music_info(music_id, quality)
|
||
except DownloadException as e:
|
||
print(f"获取音乐信息失败: {e}")
|
||
sys.exit(1)
|
||
|
||
print()
|
||
print("=" * 70)
|
||
print(f"音乐信息 (ID={info.id}, quality={info.quality})")
|
||
print("=" * 70)
|
||
|
||
info_dict = asdict(info)
|
||
for key, value in info_dict.items():
|
||
# 歌词太长,只显示行数
|
||
if key in ("lyric", "tlyric"):
|
||
lines = [ln for ln in (value or "").splitlines() if ln.strip()]
|
||
preview = " | ".join(lines[:2])[:80] if lines else "(空)"
|
||
print(f" {key:<14}: {len(lines)} 行 预览: {preview}{'...' if len(lines) > 2 else ''}")
|
||
continue
|
||
# URL 类字段:完整展示(如果终端窄可换行)
|
||
if key in ("download_url", "pic_url"):
|
||
print(f" {key:<14}: {value}")
|
||
continue
|
||
print(f" {key:<14}: {value}")
|
||
|
||
print("=" * 70)
|
||
|
||
# 可选:触发下载
|
||
if do_download:
|
||
print()
|
||
print("开始下载...")
|
||
result = downloader.download_music_file(music_id, quality)
|
||
if result.success:
|
||
print(f"下载完成: {result.file_path} ({result.file_size} 字节)")
|
||
else:
|
||
print(f"下载失败: {result.error_message}")
|
||
sys.exit(1)
|