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/_http.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

64 lines
2.1 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 -*-
"""HTTP 会话复用与音频内容嗅探。
(自 music_downloader.py 拆出,行为与原实现一致。)
"""
from __future__ import annotations
import threading
import filetype
from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests)
# ============ HTTP 连接复用(提速核心) ============
# 常见浏览器 UA,避免直链 CDN 因空 UA 限速/拒绝
DEFAULT_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
# 音频流式下载分块大小(256KB):减少系统调用与磁盘 IO 次数,提升吞吐
DL_CHUNK_SIZE = 256 * 1024
# 线程本地 Session:同线程内复用 TCP/TLS 连接(keep-alive),批量并发显著提速
_SESSION_LOCAL = threading.local()
# M4A/MP4 音频在 filetype 里被判为 video/mp4——mp4 是复合容器,仅凭魔数无法
# 区分音频轨与视频轨,故显式放行该 mime。
_CONTAINER_MIMES = frozenset({'video/mp4'})
def _sniff_audio_header(path) -> bool:
"""按魔数判定文件是否为音频容器(MP3/FLAC/OGG/Opus/WAV/M4A/MP4)。
未知期望尺寸的下载走"非空即通过"保留口径,本函数补上内容防线——
CDN 错误页/风控 JSON 不再能被存成音频交付。
识别交给 filetype(内置完整类型表,覆盖各格式的多种帧头变体),
不再自行维护魔数前缀与 ftyp 偏移;对空文件/未知格式返回 False。
"""
try:
kind = filetype.guess(str(path))
except OSError:
return False
if kind is None:
return False
mime = (kind.mime or '').lower()
return mime.startswith('audio/') or mime in _CONTAINER_MIMES
def get_http_session() -> requests.Session:
"""获取当前线程的共享同步会话(连接池由底层 aiohttp 统一维护)。"""
session: requests.Session | None = getattr(_SESSION_LOCAL, "session", None)
if session is None:
session = requests.Session()
session.headers.update({"User-Agent": DEFAULT_UA})
_SESSION_LOCAL.session = session
return session