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 6288a98ecc refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留
大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述,
会误导后来者按不复存在的架构去改代码。

拆分(对外导入路径不变,行为零变更):
- music_downloader.py  -> music_downloader/(models/downloader/cover/_http/_log/_concurrency)
- auth_system.py       -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...)
- music_api.py         -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors)
- services/runtime.py  -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/
  streaming/tasks);services/runtime.py 保留为兼容门面

兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点
(auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性
动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。

修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字
等价」校验逐一定位,静态检查只会报出其中 4 处):
- core/config.py  ).strip() or '*'
- core/state.py   threading.Lock() x2
- qr_login.py     datetime.now() / .strip()
- music_downloader/downloader.py  .lower()
- services/podcast.py  set() / time.time() x2

清理与删除:
- 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt
- 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等
  失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明)
- _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含
- pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败

验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed;
真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
2026-09-26 12:37:48 +08:00

777 lines
37 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 shutil
import subprocess
import tempfile
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 ._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,
EXT_TO_FFMPEG_FORMAT,
METADATA_FORMATS,
DownloadCanceled,
DownloadException,
DownloadResult,
MusicInfo,
_RetryFromScratch,
)
# ============ 核心实现 ============
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,
ffmpeg_path: str | None = None,
global_max_concurrent: int = 10,
):
"""
Args:
download_dir: 下载目录
max_concurrent: 单个批量任务内的最大并发下载数
ffmpeg_path: ffmpeg 可执行文件路径,为 None 时启动时检测一次
global_max_concurrent: 整个服务同时下载的文件数(含 ffmpeg 元数据写入)。
所有请求共享该信号量,防止大量用户同时下载导致内存/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)
# ffmpeg 元数据写入专用信号量(CPU 密集操作):
# 与下载分离限流 —— 元数据写满时只阻塞元数据,不占下载名额、不拖慢其它文件的传输
cpu_count = os.cpu_count() or 2
self._meta_semaphore = threading.BoundedSemaphore(max(1, min(4, cpu_count)))
# ffmpeg 路径缓存:启动时检测一次,写入时还会再懒加载一次
self.ffmpeg_path = ffmpeg_path or shutil.which("ffmpeg")
if not self.ffmpeg_path:
logger.warning("未检测到 ffmpeg,写入元数据功能将不可用")
# ----------- 工具方法 -----------
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"
def _resolve_ffmpeg(self) -> str | None:
"""懒加载解析 ffmpeg 路径
每次写入前调用一次:若启动时未检测到,但中途已安装,下一次就能用。
"""
if self.ffmpeg_path:
return self.ffmpeg_path
path = shutil.which("ffmpeg")
if path:
self.ffmpeg_path = path # 回写缓存
return path
@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("下载已取消")
# 同一目标文件的下载者串行化(不同文件之间仍然并行)
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._meta_semaphore:
self._write_metadata_with_ffmpeg(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 = file_path.with_name(file_path.name + ".part")
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._meta_semaphore:
self._write_metadata_with_ffmpeg(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:
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 = 500 * 1024 * 1024
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()
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._meta_semaphore:
self._write_metadata_with_ffmpeg(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 "下载失败")
# ============ ffmpeg 元数据写入(核心) ============
# download_music_to_memory / download_batch_async / get_download_progress
# 三个方法已删除——全服务端与验证脚本零调用(CLI 遗留),且 to_memory 不接受
# cookies 参数、真被启用会有行为差异;如需内存下载/异步批量再按当前接口重写
def _write_metadata_with_ffmpeg(
self,
file_path: Path,
music_info: MusicInfo,
fields: list[str] | None = None,
) -> bool:
"""使用 ffmpeg 写入音频元数据
处理: title/artist/album/track + 封面(如果是 mp3/flac/m4a/mp4)
fields: 可选字段列表 ['title','artist','album','cover','lyrics','date'];
None 表示全部写入。
Returns:
是否写入成功(失败不影响主流程)
"""
ffmpeg_path = self._resolve_ffmpeg()
if not ffmpeg_path:
logger.warning("跳过元数据写入: ffmpeg 不可用")
return False
file_ext = file_path.suffix.lower()
if file_ext not in METADATA_FORMATS:
logger.warning(f"跳过元数据写入: 不支持的格式 {file_ext}")
return False
if not file_path.exists():
logger.warning(f"跳过元数据写入: 文件不存在 {file_path}")
return False
# 临时输出文件:保留原扩展名在最后,便于 ffmpeg 识别 muxer
# 例: 艾辰 - 错位时空.tagging.flac
tmp_output = file_path.with_name(
file_path.stem + ".tagging" + file_path.suffix
)
# 临时封面文件
cover_tmp: str | None = None
cover_ok = False # 创建成功与否与"路径是否需要清理"分离管理
try:
# 下载封面到临时文件(走共享缓存:与 ZIP 打包共用一次网络请求)
include_cover = fields is None or "cover" in fields
if include_cover and music_info.pic_url:
cover_data = fetch_cover_bytes(music_info.pic_url)
if cover_data:
try:
fd, cover_tmp = tempfile.mkstemp(suffix=".jpg", dir=str(self.download_dir))
os.close(fd)
with open(cover_tmp, "wb") as cf:
cf.write(cover_data)
cover_ok = True
except Exception as e:
# cover_tmp 保持指向已创建的文件,finally 统一清理
# 原实现置 None 会把已创建的零字节文件遗留在下载目录)
logger.warning(f"封面写盘失败,跳过封面写入: {e}")
else:
logger.warning("封面下载失败,跳过封面写入")
# 构造 ffmpeg 命令
# 顺序: global opts -> -i input1 -> -i input2 -> metadata -> mapping -> -f format -> output
output_format = EXT_TO_FFMPEG_FORMAT.get(file_ext, "")
cmd: list[str] = [
ffmpeg_path, "-y", "-loglevel", "error",
"-i", str(file_path),
]
use_cover = cover_ok
# OGG/Opus 的封面流 copy 不被支持(Unsupported codec id),
# 主动降级为"只写文本元数据",不再让整个写入失败
if use_cover and file_ext in (".ogg", ".opus"):
use_cover = False
logger.info("OGG/Opus 不支持封面写入,仅写入文本元数据")
if use_cover and cover_tmp:
cmd += ["-i", cover_tmp]
# 通用元数据(仅写入勾选的字段)
meta = {}
# 前端字段名为 name,与后端 title 统一映射
if fields is None or "title" in fields or "name" in fields:
meta["title"] = music_info.name
if fields is None or "artist" in fields:
meta["artist"] = music_info.artists
meta["album_artist"] = music_info.artists
if fields is None or "album" in fields:
meta["album"] = music_info.album
if fields is None or "date" in fields:
if music_info.publish_year:
meta["date"] = str(music_info.publish_year)
meta["year"] = str(music_info.publish_year)
if fields is None or "lyrics" in fields:
if music_info.lyric and music_info.lyric.strip():
# 去掉 LRC 时间标签,写入纯歌词
clean_lyric = re.sub(
r"\[\d{1,2}:\d{1,2}([.:]\d+)?\]", "", music_info.lyric
).strip()
if clean_lyric:
meta["lyrics"] = clean_lyric
if music_info.track_number > 0 and (fields is None or "track" in fields):
# 只写当前曲号;没有真实专辑总数时不虚构 tracktotal
meta["track"] = str(music_info.track_number)
for k, v in meta.items():
if v:
cmd += ["-metadata", f"{k}={v}"]
# 各格式的 mapping / 编码选项
cmd += self._build_format_args(file_ext, has_cover=use_cover)
# -f 必须放在所有 -i 之后(作为 output option)才生效
if output_format:
cmd += ["-f", output_format]
cmd.append(str(tmp_output))
# 执行
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=120,
encoding="utf-8",
errors="ignore",
)
if result.returncode == 0 and tmp_output.exists():
# os.replace 才是同卷原子替换——shutil.move 在目标已
# 存在时退化为 copy2+unlink,进程中断会留半截文件
os.replace(str(tmp_output), str(file_path))
return True
# ffmpeg stderr 原文走 logger(errors='ignore' 解码后的任意 Unicode
# 在 GBK 重定向下裸 print 会崩 worker)
logger.warning(
f"ffmpeg 写入元数据失败: {(result.stderr or result.stdout or '').strip()}"
)
return False
except subprocess.TimeoutExpired:
logger.warning("ffmpeg 执行超时")
return False
except FileNotFoundError:
logger.warning(f"ffmpeg 可执行文件未找到: {ffmpeg_path}")
return False
except Exception as e:
logger.warning(f"ffmpeg 写入元数据异常: {e}")
return False
finally:
# 清理封面临时文件
if cover_tmp and os.path.exists(cover_tmp):
try:
os.unlink(cover_tmp)
except OSError:
pass
# 清理可能残留的临时输出
if tmp_output.exists():
try:
tmp_output.unlink()
except OSError:
pass
def _build_format_args(self, file_ext: str, has_cover: bool) -> list[str]:
"""根据文件格式构造 mapping / 编码参数
不写封面时用 -map 0 -c copy 保留全部既有流(含已有封面),
原实现 -map 0:a 会把 MP3/M4A 已有封面静默删掉。"""
if file_ext == ".mp3":
# MP3: 封面通过 ID3v2 APIC 帧写入
if has_cover:
return [
"-map", "0:a", "-map", "1:v",
"-c:a", "copy", "-c:v", "copy",
"-id3v2_version", "3",
"-metadata:s:v", "title=Album cover",
"-metadata:s:v", "comment=Cover (front)",
]
return ["-map", "0", "-c", "copy", "-id3v2_version", "3"]
if file_ext == ".flac":
# FLAC: 原生支持封面
if has_cover:
return [
"-map", "0", "-map", "1",
"-c", "copy",
"-disposition:v:0", "attached_pic",
]
return ["-map", "0", "-c", "copy"]
if file_ext == ".m4a":
# M4A: AAC 音频 + mp4 容器
if has_cover:
return [
"-map", "0:a", "-map", "1:v",
"-c", "copy",
"-disposition:v:0", "attached_pic",
"-metadata:s:v", "title=Album cover",
"-metadata:s:v", "comment=Cover (front)",
]
# 保留既有封面流(原 -map 0:a 会删掉已有封面)
return ["-map", "0", "-c", "copy"]
if file_ext == ".mp4":
# MP4 (杜比全景声 EAC3 / 其它 mp4 容器音频)
# 杜比文件通常无视频流,封面作为 video stream 注入;+faststart 让 moov 在文件头
if has_cover:
return [
"-map", "0", "-map", "1:v",
"-c", "copy",
"-disposition:v:0", "attached_pic",
"-metadata:s:v", "title=Album cover",
"-metadata:s:v", "comment=Cover (front)",
"-movflags", "+faststart",
]
return ["-map", "0", "-c", "copy", "-movflags", "+faststart"]
if file_ext in (".ogg", ".opus"):
if has_cover:
return [
"-map", "0:a", "-map", "1:v",
"-c:a", "copy", "-c:v", "copy",
]
return ["-map", "0:a", "-c:a", "copy"]
# 默认
if has_cover:
return ["-map", "0:a", "-map", "1:v", "-c", "copy"]
return ["-map", "0:a", "-c:a", "copy"]