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

615 lines
29 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 -*-
"""下载主类:落盘、元数据写入与 ZIP 打包。
(自 music_downloader.py 拆出,行为与原实现一致。)
"""
from __future__ import annotations
import os
import re
import threading
import time
from pathlib import Path
from core import sync_http as requests # 同步门面(底层 aiohttp,替代 requests)
from core.async_bridge import run_sync
from music_api import APIException, NeteaseAPI, load_cookies
from ._aria2 import Aria2Backend, get_backend
from ._concurrency import (
CountingSemaphore,
get_file_lock,
)
from ._http import (
DL_CHUNK_SIZE,
_sniff_audio_header,
get_http_session,
)
from ._log import (
logger,
)
from .cover import (
fetch_cover_bytes,
)
from .models import (
API_TYPE_TO_EXT,
DownloadCanceled,
DownloadException,
DownloadResult,
MusicInfo,
_RetryFromScratch,
)
from .tagging import build_tags, write_tags
# 单文件下载体积硬上限:超限即弃,防止单文件拖垮磁盘
_MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024
# ============ 核心实现 ============
class MusicDownloader:
"""网易云音乐下载器
用法:
downloader = MusicDownloader(download_dir="downloads")
result = downloader.download_music_file(music_id, quality="lossless")
"""
def __init__(
self,
download_dir: str = "downloads",
max_concurrent: int = 3,
global_max_concurrent: int = 10,
):
"""
Args:
download_dir: 下载目录
max_concurrent: 单个批量任务内的最大并发下载数
global_max_concurrent: 整个服务同时下载的文件数(含标签写入)。
所有请求共享该信号量,防止大量用户同时下载导致内存/CPU 耗尽。
"""
self.download_dir = Path(download_dir)
self.download_dir.mkdir(exist_ok=True)
self.max_concurrent = max_concurrent
self.api = NeteaseAPI()
# 全局并发信号量:所有请求共用,限制同时进行的下载数
# (带计数,供前端实时展示排队情况)
self._download_semaphore = CountingSemaphore(global_max_concurrent)
# 标签写入专用信号量:写标签会重写整个文件(IO 密集),
# 与下载分离限流 —— 写满时只阻塞后来者,不占下载名额、不拖慢其它文件的传输
cpu_count = os.cpu_count() or 2
self._tag_semaphore = threading.BoundedSemaphore(max(1, min(4, cpu_count)))
# ----------- 工具方法 -----------
def _sanitize_filename(self, filename: str, max_bytes: int = 180) -> str:
"""清理文件名非法字符。
换行/回车/制表等控制字符一律剔除(否则 send_file 头部 500);
按 UTF-8 字节截断(Linux 文件系统 255 字节上限,中文按字符截 200 会超),
且从 _build_file_path 传入时保证尾部 [ID 音质] 标记不被截掉。"""
filename = re.sub(r'[\x00-\x1f\x7f]', '', filename)
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
filename = filename.strip(' .')
# 按字节截断(在字符边界上回退,不产生半个字符)
raw = filename.encode('utf-8')
if len(raw) > max_bytes:
cut = filename.encode('utf-8')[:max_bytes]
# 回退到合法 UTF-8 边界
while cut and (cut[-1] & 0xC0) == 0x80:
cut = cut[:-1]
filename = cut.decode('utf-8', errors='ignore').rstrip(' .')
return filename or "unknown"
def _determine_file_extension(
self,
url: str,
content_type: str = "",
api_type: str = "",
) -> str:
"""确定文件扩展名
优先级: API 返回的 type > URL 后缀 > HTTP Content-Type > 默认 mp3
"""
api_type = (api_type or "").lower()
if api_type in API_TYPE_TO_EXT:
return API_TYPE_TO_EXT[api_type]
url_lower = url.lower()
if ".flac" in url_lower:
return ".flac"
if ".mp3" in url_lower:
return ".mp3"
if ".m4a" in url_lower or ".mp4" in url_lower:
return ".m4a"
content_type = (content_type or "").lower()
if "flac" in content_type:
return ".flac"
if "mpeg" in content_type or "mp3" in content_type:
return ".mp3"
if "mp4" in content_type or "m4a" in content_type:
return ".m4a"
return ".mp3"
@staticmethod
def _unlink_quiet(path: Path) -> None:
"""安静地删除文件,忽略不存在 / 权限错误"""
try:
path.unlink()
except OSError:
pass
# ----------- 信息获取 -----------
def get_music_info(self, music_id: int, quality: str = "standard",
cookies: dict[str, str] | None = None) -> MusicInfo:
"""获取音乐详细信息(同步,URL/详情/歌词 3 个接口并行请求,加快单曲/批量启动)"""
try:
cookies = cookies if cookies is not None else load_cookies()
# 3 个接口并行请求,单曲下载/批量预取都能明显提速
# URL/详情拿到后立即取消未完成的歌词请求,已知失败不再被拖着等完
# 异步化:改用 asyncio.gather 并发(api 方法已异步),失败语义与旧线程池一致
import asyncio
async def _gather_info():
url_t = self.api.get_song_url(music_id, quality, cookies)
detail_t = self.api.get_song_detail(music_id)
lyric_t = self.api.get_lyric(music_id, cookies)
return await asyncio.gather(url_t, detail_t, lyric_t, return_exceptions=True)
url_result, detail_result, lyric_result = run_sync(_gather_info())
if isinstance(url_result, BaseException):
raise url_result
if isinstance(detail_result, BaseException):
raise detail_result
if isinstance(lyric_result, BaseException):
lyric_result = None # 歌词接口抖动不阻断解析/下载
if not url_result.get("data") or not url_result["data"]:
raise DownloadException(f"无法获取音乐ID {music_id} 的播放链接")
# 上游 data 数组元素可能为 null(同族)
song_data = url_result["data"][0] or {}
download_url = song_data.get("url", "") or ""
if not download_url:
raise DownloadException(f"音乐ID {music_id} 无可用的下载链接")
if not detail_result.get("songs") or not detail_result["songs"]:
raise DownloadException(f"无法获取音乐ID {music_id} 的详细信息")
song_detail = detail_result["songs"][0]
# 上游可选字段可能为 null(而非缺失),一律 (x or {}) / (x or []) 容错,
# 可选展示信息异常不再让整首有效歌曲解析失败
lyric = ((lyric_result or {}).get("lrc") or {}).get("lyric", "") or ""
tlyric = ((lyric_result or {}).get("tlyric") or {}).get("lyric", "") or ""
artists = "/".join(filter(None, ((a or {}).get("name") for a in (song_detail.get("ar") or []))))
al = song_detail.get("al") or {}
publish_time = al.get("publishTime", 0) or 0
publish_year = None
if publish_time:
import datetime
try:
publish_year = datetime.datetime.fromtimestamp(publish_time / 1000).year
except (OSError, ValueError, OverflowError):
publish_year = None
return MusicInfo(
id=music_id,
name=song_detail.get("name") or "未知歌曲",
artists=artists or "未知艺术家",
album=al.get("name") or "未知专辑",
pic_url=al.get("picUrl") or "",
duration=(song_detail.get("dt") or 0) // 1000,
track_number=song_detail.get("no", 0) or 0,
download_url=download_url,
file_type=(song_data.get("type") or "mp3").lower(), # null 容错
file_size=song_data.get("size", 0) or 0,
quality=quality,
lyric=lyric,
tlyric=tlyric,
publish_year=publish_year,
)
except APIException as e:
raise DownloadException(f"API调用失败: {e}") from e
except Exception as e:
raise DownloadException(f"获取音乐信息时发生错误: {e}") from e
# ----------- 文件路径 -----------
def _build_file_path(self, music_info: MusicInfo) -> Path:
"""根据 MusicInfo 生成下载文件路径
文件名附带歌曲 ID 与请求音质:不同歌曲同名(不同版本/重复收录)落盘互不冲突;
同一首歌不同音质也各自独立,避免低音质文件被当作高音质交付(调包)。
交付文件名由调用方另行决定,不受影响。
先截断主名(按字节),再拼接 [ID 音质] 标记——标记永不丢失,
杜绝截断后两首不同 ID 的歌落到同一路径。
"""
base = f"{music_info.artists} - {music_info.name}"
tag = f" [{music_info.id} {music_info.quality}]"
ext = self._determine_file_extension(
music_info.download_url,
api_type=music_info.file_type,
)
# 按完整路径反算文件名可用长度——旧口径只按 Linux 255 字节
# 截断文件名,未启用 LongPathsEnabled 的 Windows 上超长歌名会在 open 时
# OSError 白白重试 3 轮。预算 = 240 - 目录前缀 - 扩展名 - ".part"等余量;
# 中文名 UTF-8 字节数 ≥ 字符数,按字节截断天然保守。
# 目录过深时按比例压缩 tag 预算。注意下限保底仍可能超过 260 字节,
# 该病理场景需由部署侧控制 downloads 目录深度来规避。
budget = 240 - len(str(self.download_dir)) - len(ext) - 8
if budget < 124:
tag_budget = max(24, budget // 3)
else:
tag_budget = 60
safe_base = self._sanitize_filename(base, max_bytes=max(32, budget - tag_budget))
safe_tag = self._sanitize_filename(tag, max_bytes=tag_budget)
return self.download_dir / f"{safe_base}{safe_tag}{ext}"
# ----------- 下载(同步) -----------
def download_music_file(
self,
music_id: int,
quality: str = "standard",
write_meta: bool = True,
meta_fields: list[str] | None = None,
cookies: dict[str, str] | None = None,
retries: int = 2,
cancel_event: threading.Event | None = None,
music_info: MusicInfo | None = None,
progress_cb=None,
) -> DownloadResult:
"""同步下载音乐到本地
Args:
music_id: 音乐ID
quality: 音质
write_meta: 是否写入元数据
meta_fields: 写入字段列表,None 表示全部
cookies: 用于获取直链/歌词的 Cookie,None 表示全局账号
retries: 网络/写入失败时的重试次数(默认 2 次)
cancel_event: 取消事件,置位后立即中止下载(抛 DownloadCanceled)
music_info: 已预取的歌曲信息(跳过重复 API 调用,用于批量加速)
progress_cb: 可选进度回调 progress_cb(written_bytes, total_bytes)。
每写一个分块调用一次(含续传起点);回调内部的异常会被吞掉,
绝不影响下载主流程。调用方自行限频。
说明:
- 流式下载到 .part 临时文件,完整下载后再原子改名,半截文件不会被当作已下载
- 断点续传:失败保留 .part,重试/下次下载从已写字节续传(服务器支持 Range 时)
- per-file 锁:同名歌曲并发下载互斥,防止 .part 被交叉写入损坏
- 连接复用:线程级 Session + keep-alive,避免每首歌重建 TCP/TLS 连接
- 网络传输与 ffmpeg 元数据写入分离限流,互不抢占对方资源
"""
if music_info is None:
music_info = self.get_music_info(music_id, quality, cookies)
file_path = self._build_file_path(music_info)
part_path0 = file_path.with_name(file_path.name + ".part")
last_error = ""
next_wait = 0.0
for attempt in range(retries + 1):
# 重试退避睡眠在文件锁外执行——同名并发任务不再陪睡
if next_wait > 0:
time.sleep(next_wait)
next_wait = 0.0
# 重试轮次顶部的取消检查也要清理 .part(原实现在 try 外抛出会绕过清理)
if cancel_event is not None and cancel_event.is_set():
self._unlink_quiet(part_path0)
raise DownloadCanceled("下载已取消")
# part_path 是 file_path 的 .part 别名。必须在进入下载 try 之前绑定:
# except 分支(取消/网络错误)要清理 .part,若此处未赋值,异常处理器
# 会以 NameError 二次崩溃、掩盖原始异常
part_path = part_path0
# 同一目标文件的下载者串行化(不同文件之间仍然并行)
with get_file_lock(file_path):
try:
# 其它线程可能刚好完成此文件,锁内复查
if file_path.exists():
# 缓存与本次期望不符(跨账号音质降级/上游变更都会让同 ID 同音质
# 的实际文件不同):视为缓存 miss 删除重下,而不是把旧文件
# 当本次结果交付(原实现命中即返回、从不校验尺寸)。
# 期望未知(file_size=0)时至少拒绝 0 字节坏文件
st_size = file_path.stat().st_size
cache_bad = ((music_info.file_size and st_size != music_info.file_size)
or (not music_info.file_size and st_size == 0))
if cache_bad:
self._unlink_quiet(file_path)
else:
# 缓存命中也尊重本次的元数据请求(重跑一次 ffmpeg,幂等)
if write_meta:
with self._tag_semaphore:
self._write_metadata(file_path, music_info, meta_fields)
return DownloadResult(
success=True,
file_path=str(file_path),
file_size=file_path.stat().st_size,
music_info=music_info,
)
# part_path 已在文件锁外绑定为 part_path0(同一路径),此处不再重复计算
resume_from = 0
if part_path.exists():
existing = part_path.stat().st_size
if music_info.file_size > 0 and existing == music_info.file_size:
# 上次已下完但未改名:直接转正(转正后立即返回,
# 不再发起网络请求——原实现离线时会把现成完整文件报为失败)
# 改 >= 为 ==——超尺寸残片(上游多发货/坏分片)转正即交付坏文件
os.replace(part_path, file_path)
if write_meta:
with self._tag_semaphore:
self._write_metadata(file_path, music_info, meta_fields)
return DownloadResult(
success=True,
file_path=str(file_path),
file_size=file_path.stat().st_size,
music_info=music_info,
)
elif existing > 0:
if music_info.file_size > 0 and existing > music_info.file_size:
# 超尺寸残片没有续传价值(上游多发货/坏分片),
# 丢弃从头下——否则下轮被 >= 误转正交付坏文件
self._unlink_quiet(part_path)
else:
resume_from = existing # 断点续传起点
else:
self._unlink_quiet(part_path)
# 排队改用可取消等待(的 acquire_cancellable)——
# 原阻塞式 with 使取消后仍要等全局 10 名额腾出,任务卡死、退款延迟
if not self._download_semaphore.acquire_cancellable(cancel_event, poll=0.5):
raise DownloadCanceled("下载已取消")
try:
aria2_backend = get_backend()
if aria2_backend is not None:
# aria2c 通道:多连接 + 原生断点续传
self._fetch_via_aria2(aria2_backend, music_info, part_path,
cancel_event, progress_cb)
else:
# 内置通道:aiohttp 流式落盘 + Range 起点校验
self._fetch_via_http(music_info, part_path, resume_from,
cancel_event, progress_cb)
finally:
# 与 acquire_cancellable 配对释放(原 with 语义)
self._download_semaphore.release()
# 完整性校验(已知期望大小时必须精确相等;未知期望时至少非空)
actual_size = part_path.stat().st_size
if music_info.file_size > 0:
complete = (actual_size == music_info.file_size)
else:
complete = (actual_size > 0)
if not complete:
# 保留 .part 以便断点续传;下次续传从现有字节继续
last_error = (
f"下载不完整: 期望 {music_info.file_size or '未知'} 字节, "
f"实际 {actual_size} 字节"
)
if attempt < retries:
next_wait = 0.5 * (attempt + 1) # 锁外睡眠
continue
# 转正前内容嗅探——未知期望尺寸("非空即通过"口径)
# 时 CDN 错误页/风控 JSON 曾可被存成音频交付
if music_info.file_size <= 0 and not _sniff_audio_header(part_path):
self._unlink_quiet(part_path)
raise DownloadException("下载内容校验失败(非音频数据)")
os.replace(part_path, file_path)
# 元数据写入移出下载信号量(CPU 操作不占网络名额),
# 但仍在文件锁内执行,保证其它请求看到的是已完成标记的最终文件
if write_meta:
# 元数据段入口补取消检查点——已取消的任务不再
# 进入信号量排队/ffmpeg 等待(有界但可达 120s),收尾不再延迟
if cancel_event is not None and cancel_event.is_set():
raise DownloadCanceled("下载已取消")
with self._tag_semaphore:
self._write_metadata(file_path, music_info, meta_fields)
return DownloadResult(
success=True,
file_path=str(file_path),
file_size=file_path.stat().st_size,
music_info=music_info,
)
except DownloadCanceled:
# 用户明确放弃:清理半截临时文件
self._unlink_quiet(part_path)
raise
except DownloadException:
# 确定性失败(如体积超上限)不重试——原落泛型
# handler 会删残片后从 0 重拉,3 轮白拉 ~1.5GB
raise
except _RetryFromScratch:
# .part 已清理,直接进入下一轮从头下载
last_error = "续传起点失效,重新下载"
if attempt < retries:
next_wait = 0.5 * (attempt + 1) # 锁外睡眠
continue
except (requests.RequestException, OSError) as e:
# 网络错误:保留 .part 支持断点续传;
# 但若任务已被取消,.part 没有任何续传价值,一并清理
if cancel_event is not None and cancel_event.is_set():
self._unlink_quiet(part_path)
raise DownloadCanceled("下载已取消") from e
last_error = f"下载请求失败: {e}"
if attempt < retries:
next_wait = 0.5 * (attempt + 1) # 锁外睡眠
continue
except Exception as e:
last_error = f"下载过程中发生错误: {e}"
if attempt < retries:
next_wait = 0.5 * (attempt + 1) # 锁外睡眠
continue
# 最后一次尝试仍失败:若任务已取消按取消收尾
if cancel_event is not None and cancel_event.is_set():
self._unlink_quiet(part_path0)
raise DownloadCanceled("下载已取消")
return DownloadResult(success=False, error_message=last_error or "下载失败")
# ----------- 传输通道(二选一,产物都是 part_path) -----------
def _fetch_via_http(
self,
music_info: MusicInfo,
part_path: Path,
resume_from: int,
cancel_event: threading.Event | None,
progress_cb,
) -> None:
"""内置流式通道:core.sync_http 逐块落盘,支持 Range 断点续传。
Raises:
DownloadCanceled: 取消事件置位
DownloadException: 体积超限、CDN 4xx 等确定性失败
_RetryFromScratch: 续传起点失效(残片已清,外层从头重下)
"""
headers = {}
mode = "wb"
if resume_from > 0:
headers["Range"] = f"bytes={resume_from}-"
mode = "ab"
if progress_cb is not None:
try:
progress_cb(resume_from, music_info.file_size or 0)
except Exception:
pass # 进度回调绝不影响下载主流程
resp: requests.SyncResponse | None = None
try:
resp = get_http_session().get(
music_info.download_url,
stream=True,
timeout=(10, 60),
headers=headers,
)
# 服务器忽略 Range 返回 200 → 从头重写而非追加
if resume_from > 0 and resp.status_code != 206:
mode = "wb"
# 206 必须从我们请求的起点开始;起点错位的分片拼起来是坏文件
if resume_from > 0 and resp.status_code == 206:
cr = resp.headers.get('Content-Range') or ''
m = re.match(r'bytes\s+(\d+)-', cr.strip())
if m and int(m.group(1)) != resume_from:
self._unlink_quiet(part_path)
raise _RetryFromScratch(
f"Content-Range 起点错位({m.group(1)} != {resume_from}),已从头重新下载")
# 206 却解析不出起点(头被中间层剥离)——盲追加可能拼坏文件,
# 与起点错位同口径处理:清残片从头下
if not m:
self._unlink_quiet(part_path)
raise _RetryFromScratch("206 响应缺少可用 Content-Range,已从头重新下载")
resp.raise_for_status()
# 下载体积硬上限(配置 500MB),超限即弃,防单文件拖垮磁盘
max_bytes = _MAX_DOWNLOAD_BYTES
written = resume_from if mode == 'ab' else 0
over_limit = False
with open(part_path, mode) as f:
for chunk in resp.iter_content(chunk_size=DL_CHUNK_SIZE):
if cancel_event is not None and cancel_event.is_set():
raise DownloadCanceled("下载已取消")
if chunk:
f.write(chunk)
written += len(chunk)
if written > max_bytes:
# 超限残片没有任何续传价值。
# Windows 无法删除打开中的文件——置标志 break,
# with 关闭后再清理(原实现在打开中 unlink 被静默吞掉)
over_limit = True
break
if progress_cb is not None:
try:
progress_cb(written, music_info.file_size or 0)
except Exception:
pass # 进度回调绝不影响下载主流程
if over_limit:
self._unlink_quiet(part_path) # 文件已关闭,清理可达
raise DownloadException(
f"文件超过大小上限 {max_bytes // (1024*1024)}MB,已中止")
except requests.HTTPError as e:
# Range 起点无效(如 CDN 环节变化):清除旧 part,从头重新下载
status = getattr(getattr(e, "response", None), "status_code", None)
if status == 416:
self._unlink_quiet(part_path)
raise _RetryFromScratch("Range 起点失效,已从头重新下载") from e
if status is not None and 400 <= status < 500 and status != 408:
# CDN 4xx(403 防盗链/429 风控等)是确定性
# 失败,对同一失效直链白重试只会加剧风控、拖慢失败返回
# (408 请求超时保留可重试)
self._unlink_quiet(part_path)
raise DownloadException(
f"下载失败(HTTP {status}),下载链接可能已失效") from e
raise
finally:
if resp is not None:
resp.close()
def _fetch_via_aria2(
self,
backend: Aria2Backend,
music_info: MusicInfo,
part_path: Path,
cancel_event: threading.Event | None,
progress_cb,
) -> None:
"""aria2c 通道:多连接加速 + 原生断点续传。
异常语义与内置通道对齐:取消抛 DownloadCanceled、体积超限抛
DownloadException(均清残片),网络类失败抛 RequestException
(保留残片,由外层退避重试续传)。
"""
backend.fetch(
music_info.download_url,
part_path,
cancel_event=cancel_event,
progress_cb=progress_cb,
max_bytes=_MAX_DOWNLOAD_BYTES,
)
# ============ 元数据写入(mutagen,纯 Python) ============
# download_music_to_memory / download_batch_async / get_download_progress
# 三个方法已删除——全服务端与验证脚本零调用(CLI 遗留),且 to_memory 不接受
# cookies 参数、真被启用会有行为差异;如需内存下载/异步批量再按当前接口重写
def _write_metadata(
self,
file_path: Path,
music_info: MusicInfo,
fields: list[str] | None = None,
) -> bool:
"""写入音频标签与封面(mutagen);失败不影响交付,返回是否成功。
Args:
file_path: 已转正的音频文件
music_info: 歌曲信息(标题/歌手/专辑/年份/曲号/歌词/封面地址)
fields: 要写入的字段列表,None 表示全部写入
封面走共享缓存(与 ZIP 打包共用一次网络请求);各容器的落盘细节见
tagging.py——按扩展名分派到 ID3v2.3 / Vorbis comment / iTunes ilst。
"""
include_cover = fields is None or "cover" in fields
cover: bytes | None = None
if include_cover and music_info.pic_url:
cover = fetch_cover_bytes(music_info.pic_url)
if not cover:
logger.warning("封面下载失败,跳过封面写入")
return write_tags(file_path, build_tags(music_info, fields), cover)