Archived
为什么:原先三处都在手写轮子——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
241 lines
7.9 KiB
Python
241 lines
7.9 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""音频标签与封面写入(mutagen 纯 Python 实现,不需要 ffmpeg 命令)。
|
||
|
||
为什么不再用 ffmpeg CLI:本工程的元数据写入只做「改标签」,不做任何重编码,
|
||
而 ffmpeg 命令行必须出现**重复的** `-metadata` / `-map` 参数;ffmpeg-python 用
|
||
`**kwargs` 字典建模这些选项,结构上无法表达重复键(实测把列表值字符串化成
|
||
`"['0', '1:v']"`),绕一圈仍需手写命令拼接。改用 mutagen 后整段变成纯 Python
|
||
调用,部署镜像也不必再装 ffmpeg。
|
||
|
||
覆盖容器:
|
||
- MP3 ID3v2.3(TIT2 / TPE1 / TPE2 / TALB / TDRC / TRCK / USLT / APIC)
|
||
- FLAC Vorbis comment(title / artist / albumartist / …)+ Picture 块
|
||
- M4A / MP4 iTunes ilst(©nam / ©ART / aART / ©alb / ©day / trkn / ©lyr)+ covr
|
||
- OGG / Opus Vorbis comment + METADATA_BLOCK_PICTURE(base64 编码的 Picture 块)
|
||
|
||
写入一律走 mutagen 的原子替换(临时文件 + os.replace),中途失败不会留下半截文件;
|
||
且是「就地改帧」,原有未触及的标签(TXXX 等)会被保留。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import re
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from ._log import logger
|
||
from .models import METADATA_FORMATS, MusicInfo
|
||
|
||
# 封面类型:ID3 APIC / FLAC Picture 的 picture type 3 = Cover (front)
|
||
_PICTURE_TYPE_COVER_FRONT = 3
|
||
_COVER_DESC = 'Cover (front)'
|
||
_PNG_MAGIC = b'\x89PNG\r\n\x1a\n'
|
||
|
||
# LRC 时间标签([mm:ss] / [mm:ss.xx] / [mm:ss:xx])
|
||
_LRC_TIME_TAG = re.compile(r'\[\d{1,2}:\d{1,2}([.:]\d+)?\]')
|
||
|
||
|
||
@dataclass
|
||
class TagSet:
|
||
"""与容器无关的标签中间表示;None 表示本次不写该字段。"""
|
||
|
||
title: str | None = None
|
||
artist: str | None = None
|
||
album: str | None = None
|
||
album_artist: str | None = None
|
||
date: str | None = None
|
||
track: int | None = None
|
||
lyrics: str | None = None
|
||
|
||
|
||
def build_tags(info: MusicInfo, fields: list[str] | None) -> TagSet:
|
||
"""按前端勾选的字段构造标签集合;fields 为 None 表示全部写入。"""
|
||
def want(*names: str) -> bool:
|
||
return fields is None or any(n in fields for n in names)
|
||
|
||
tags = TagSet()
|
||
if want('title', 'name'):
|
||
tags.title = info.name or None
|
||
if want('artist'):
|
||
tags.artist = info.artists or None
|
||
tags.album_artist = info.artists or None
|
||
if want('album'):
|
||
tags.album = info.album or None
|
||
if want('date') and info.publish_year:
|
||
tags.date = str(info.publish_year)
|
||
if want('lyrics') and info.lyric:
|
||
# 写入纯歌词:去掉 LRC 时间标签
|
||
tags.lyrics = _LRC_TIME_TAG.sub('', info.lyric).strip() or None
|
||
if want('track') and info.track_number > 0:
|
||
# 只写当前曲号;没有真实专辑总数时不虚构 tracktotal
|
||
tags.track = info.track_number
|
||
return tags
|
||
|
||
|
||
def write_tags(path: Path, tags: TagSet, cover: bytes | None = None) -> bool:
|
||
"""把标签与封面写入音频文件;失败仅记日志并返回 False(不影响交付主流程)。"""
|
||
ext = path.suffix.lower()
|
||
writer = _WRITERS.get(ext)
|
||
if writer is None:
|
||
logger.warning(f"跳过元数据写入: 不支持的格式 {ext}")
|
||
return False
|
||
if not path.exists():
|
||
logger.warning(f"跳过元数据写入: 文件不存在 {path}")
|
||
return False
|
||
try:
|
||
writer(path, tags, cover)
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"写入元数据失败({ext}): {e}")
|
||
return False
|
||
|
||
|
||
# ---------- 各容器实现 ----------
|
||
|
||
def _write_mp3(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||
from mutagen.id3 import (
|
||
APIC,
|
||
ID3,
|
||
TALB,
|
||
TDRC,
|
||
TIT2,
|
||
TPE1,
|
||
TPE2,
|
||
TRCK,
|
||
USLT,
|
||
ID3NoHeaderError,
|
||
)
|
||
|
||
try:
|
||
audio = ID3(str(path))
|
||
except ID3NoHeaderError:
|
||
audio = ID3() # 无 ID3 头的裸音频:新建标签区
|
||
if tags.title:
|
||
audio['TIT2'] = TIT2(encoding=3, text=[tags.title])
|
||
if tags.artist:
|
||
audio['TPE1'] = TPE1(encoding=3, text=[tags.artist])
|
||
if tags.album_artist:
|
||
audio['TPE2'] = TPE2(encoding=3, text=[tags.album_artist])
|
||
if tags.album:
|
||
audio['TALB'] = TALB(encoding=3, text=[tags.album])
|
||
if tags.date:
|
||
audio['TDRC'] = TDRC(encoding=3, text=[tags.date])
|
||
if tags.track:
|
||
audio['TRCK'] = TRCK(encoding=3, text=[str(tags.track)])
|
||
if tags.lyrics:
|
||
audio['USLT'] = USLT(encoding=3, lang='eng', desc='', text=tags.lyrics)
|
||
if cover:
|
||
audio['APIC'] = APIC(encoding=3, mime=_cover_mime(cover),
|
||
type=_PICTURE_TYPE_COVER_FRONT,
|
||
desc=_COVER_DESC, data=cover)
|
||
# ID3v2.3 与 ffmpeg 时代的 -id3v2_version 3 保持一致(mutagen 会自动把
|
||
# TDRC 降级为 TYER/TDAT);不写 v1 标签以免在文件尾追加 128 字节
|
||
audio.save(str(path), v2_version=3)
|
||
|
||
|
||
def _write_flac(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||
from mutagen.flac import FLAC
|
||
|
||
audio = FLAC(str(path))
|
||
_set_vorbis_comments(audio, tags)
|
||
if cover:
|
||
audio.clear_pictures()
|
||
audio.add_picture(_make_picture(cover))
|
||
audio.save()
|
||
|
||
|
||
def _write_m4a(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||
from mutagen.mp4 import MP4, MP4Cover
|
||
|
||
audio = MP4(str(path))
|
||
mapping = {
|
||
'\xa9nam': tags.title,
|
||
'\xa9ART': tags.artist,
|
||
'aART': tags.album_artist,
|
||
'\xa9alb': tags.album,
|
||
'\xa9day': tags.date,
|
||
'\xa9lyr': tags.lyrics,
|
||
}
|
||
for key, value in mapping.items():
|
||
if value:
|
||
audio[key] = [value]
|
||
if tags.track:
|
||
audio['trkn'] = [(tags.track, 0)] # 不虚构 tracktotal
|
||
if cover:
|
||
image_format = (MP4Cover.FORMAT_PNG if cover.startswith(_PNG_MAGIC)
|
||
else MP4Cover.FORMAT_JPEG)
|
||
audio['covr'] = [MP4Cover(cover, imageformat=image_format)]
|
||
audio.save()
|
||
|
||
|
||
def _write_ogg(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||
_write_ogg_stream(path, tags, cover, opus=False)
|
||
|
||
|
||
def _write_opus(path: Path, tags: TagSet, cover: bytes | None) -> None:
|
||
_write_ogg_stream(path, tags, cover, opus=True)
|
||
|
||
|
||
def _write_ogg_stream(path: Path, tags: TagSet, cover: bytes | None, *,
|
||
opus: bool) -> None:
|
||
from mutagen.oggopus import OggOpus
|
||
from mutagen.oggvorbis import OggVorbis
|
||
|
||
audio: Any = OggOpus(str(path)) if opus else OggVorbis(str(path))
|
||
_set_vorbis_comments(audio, tags)
|
||
if cover:
|
||
# Ogg 系把封面按 METADATA_BLOCK_PICTURE 规范存成 base64 的 Picture 块
|
||
audio['metadata_block_picture'] = [
|
||
base64.b64encode(_make_picture(cover).write()).decode('ascii')]
|
||
audio.save()
|
||
|
||
|
||
# ---------- 小工具 ----------
|
||
|
||
def _cover_mime(cover: bytes) -> str:
|
||
return 'image/png' if cover.startswith(_PNG_MAGIC) else 'image/jpeg'
|
||
|
||
|
||
def _make_picture(cover: bytes) -> Any:
|
||
from mutagen.flac import Picture
|
||
|
||
pic = Picture()
|
||
pic.type = _PICTURE_TYPE_COVER_FRONT
|
||
pic.mime = _cover_mime(cover)
|
||
pic.desc = _COVER_DESC
|
||
pic.data = cover
|
||
return pic
|
||
|
||
|
||
def _set_vorbis_comments(audio: Any, tags: TagSet) -> None:
|
||
"""FLAC 与 Ogg 共用 Vorbis comment 字段名。"""
|
||
mapping = {
|
||
'title': tags.title,
|
||
'artist': tags.artist,
|
||
'albumartist': tags.album_artist,
|
||
'album': tags.album,
|
||
'date': tags.date,
|
||
}
|
||
for key, value in mapping.items():
|
||
if value:
|
||
audio[key] = [value]
|
||
if tags.track:
|
||
audio['tracknumber'] = [str(tags.track)]
|
||
if tags.lyrics:
|
||
audio['lyrics'] = [tags.lyrics]
|
||
|
||
|
||
_WRITERS = {
|
||
'.mp3': _write_mp3,
|
||
'.flac': _write_flac,
|
||
'.m4a': _write_m4a,
|
||
'.mp4': _write_m4a,
|
||
'.ogg': _write_ogg,
|
||
'.opus': _write_opus,
|
||
}
|
||
|
||
# 与 models 的类型表保持同源:新增容器必须两边同时登记
|
||
assert set(_WRITERS) == set(METADATA_FORMATS), '标签写入表与 METADATA_FORMATS 不一致'
|