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/ 指纹未变;忽略注释的逐字等价性校验通过。
This commit is contained in:
snowy-yang committed 2026-09-26 12:37:48 +08:00
1 parent 838d635bc5
commit 6288a98ecc
89 files changed
+5806 -5112

No files matched your search

+776
View File
@@ -0,0 +1,776 @@
# -*- 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"]