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
This commit is contained in:
snowy-yang committed 2026-09-26 13:54:44 +08:00
1 parent 183f5744d8
commit ad8c30d932
12 files changed
+1367 -369

No files matched your search

+166 -328
View File
@@ -8,9 +8,6 @@ from __future__ import annotations
import os
import re
import shutil
import subprocess
import tempfile
import threading
import time
from pathlib import Path
@@ -19,6 +16,7 @@ from core import sync_http as requests # 同步门面(底层 aiohttp,替代
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,
@@ -36,14 +34,16 @@ from .cover import (
)
from .models import (
API_TYPE_TO_EXT,
EXT_TO_FFMPEG_FORMAT,
METADATA_FORMATS,
DownloadCanceled,
DownloadException,
DownloadResult,
MusicInfo,
_RetryFromScratch,
)
from .tagging import build_tags, write_tags
# 单文件下载体积硬上限:超限即弃,防止单文件拖垮磁盘
_MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024
# ============ 核心实现 ============
@@ -59,15 +59,13 @@ class MusicDownloader:
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 元数据写入)。
global_max_concurrent: 整个服务同时下载的文件数(含标签写入)。
所有请求共享该信号量,防止大量用户同时下载导致内存/CPU 耗尽。
"""
self.download_dir = Path(download_dir)
@@ -80,15 +78,10 @@ class MusicDownloader:
# (带计数,供前端实时展示排队情况)
self._download_semaphore = CountingSemaphore(global_max_concurrent)
# ffmpeg 元数据写入专用信号量(CPU 密集操作):
# 与下载分离限流 —— 元数据写满时只阻塞元数据,不占下载名额、不拖慢其它文件的传输
# 标签写入专用信号量:写标签会重写整个文件(IO 密集),
# 与下载分离限流 —— 写满时只阻塞后来者,不占下载名额、不拖慢其它文件的传输
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,写入元数据功能将不可用")
self._tag_semaphore = threading.BoundedSemaphore(max(1, min(4, cpu_count)))
# ----------- 工具方法 -----------
@@ -142,18 +135,6 @@ class MusicDownloader:
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:
"""安静地删除文件,忽略不存在 / 权限错误"""
@@ -328,6 +309,11 @@ class MusicDownloader:
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:
@@ -345,8 +331,8 @@ class MusicDownloader:
else:
# 缓存命中也尊重本次的元数据请求(重跑一次 ffmpeg,幂等)
if write_meta:
with self._meta_semaphore:
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
with self._tag_semaphore:
self._write_metadata(file_path, music_info, meta_fields)
return DownloadResult(
success=True,
file_path=str(file_path),
@@ -354,7 +340,7 @@ class MusicDownloader:
music_info=music_info,
)
part_path = file_path.with_name(file_path.name + ".part")
# part_path 已在文件锁外绑定为 part_path0(同一路径),此处不再重复计算
resume_from = 0
if part_path.exists():
existing = part_path.stat().st_size
@@ -364,8 +350,8 @@ class MusicDownloader:
# 改 >= 为 ==——超尺寸残片(上游多发货/坏分片)转正即交付坏文件
os.replace(part_path, file_path)
if write_meta:
with self._meta_semaphore:
self._write_metadata_with_ffmpeg(file_path, music_info, meta_fields)
with self._tag_semaphore:
self._write_metadata(file_path, music_info, meta_fields)
return DownloadResult(
success=True,
file_path=str(file_path),
@@ -387,85 +373,15 @@ class MusicDownloader:
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()
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()
@@ -501,8 +417,8 @@ class MusicDownloader:
# 进入信号量排队/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)
with self._tag_semaphore:
self._write_metadata(file_path, music_info, meta_fields)
return DownloadResult(
success=True,
@@ -548,229 +464,151 @@ class MusicDownloader:
return DownloadResult(success=False, error_message=last_error or "下载失败")
# ============ ffmpeg 元数据写入(核心) ============
# ----------- 传输通道(二选一,产物都是 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_with_ffmpeg(
def _write_metadata(
self,
file_path: Path,
music_info: MusicInfo,
fields: list[str] | None = None,
) -> bool:
"""使用 ffmpeg 写入音频元数据
"""写入音频标签与封面(mutagen);失败不影响交付,返回是否成功。
处理: title/artist/album/track + 封面(如果是 mp3/flac/m4a/mp4)
fields: 可选字段列表 ['title','artist','album','cover','lyrics','date'];
None 表示全部写入。
Args:
file_path: 已转正的音频文件
music_info: 歌曲信息(标题/歌手/专辑/年份/曲号/歌词/封面地址)
fields: 要写入的字段列表,None 表示全部写入
Returns:
是否写入成功(失败不影响主流程)
封面走共享缓存(与 ZIP 打包共用一次网络请求);各容器的落盘细节见
tagging.py——按扩展名分派到 ID3v2.3 / Vorbis comment / iTunes ilst。
"""
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"]
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)