This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/music_downloader/__main__.py
T
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

85 lines
2.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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)