mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
194 lines
6.9 KiB
Python
194 lines
6.9 KiB
Python
"""
|
||
通过真实音频容器覆盖空标签、原生 ID3 和独立音轨总数的读取契约。
|
||
"""
|
||
|
||
import hashlib
|
||
import shutil
|
||
import struct
|
||
import wave
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from mutagen import File as MutagenFile
|
||
from mutagen.flac import FLAC
|
||
from mutagen.id3 import TALB, TDRC, TIT2, TPE1, TPE2, TPOS, TRCK, TSRC, TXXX, UFID
|
||
from mutagen.wave import WAVE
|
||
|
||
from app.application.audio import AudioMetadataHelper
|
||
|
||
RECORDING_ID = "38035858-f990-4fbb-b3b2-f2f8b958eeba"
|
||
|
||
|
||
def _write_wave(path: Path) -> Path:
|
||
"""
|
||
生成三秒双声道 PCM,避免测试依赖本机 ffmpeg。
|
||
"""
|
||
with wave.open(str(path), "wb") as stream:
|
||
stream.setnchannels(2)
|
||
stream.setsampwidth(2)
|
||
stream.setframerate(44100)
|
||
stream.writeframes(b"\0" * 44100 * 4 * 3)
|
||
return path
|
||
|
||
|
||
def _write_dsf(path: Path) -> Path:
|
||
"""
|
||
生成带单个双声道 DSD 数据块的合法 DSF,供实际 ID3 读写使用。
|
||
"""
|
||
payload = b"\x69" * 8192
|
||
header = struct.pack("<4sQQQ", b"DSD ", 28, 92 + len(payload), 0)
|
||
fmt = struct.pack("<4sQIIIIIIQII", b"fmt ", 52, 1, 0, 2, 2, 2822400, 1, 32768, 4096, 0)
|
||
path.write_bytes(header + fmt + struct.pack("<4sQ", b"data", 12 + len(payload)) + payload)
|
||
return path
|
||
|
||
|
||
def _write_aiff(path: Path) -> Path:
|
||
"""
|
||
标准FORM/COMM/SSND块生成三秒44.1kHz PCM,采样率使用AIFF扩展浮点格式。
|
||
"""
|
||
common = struct.pack(">hIh", 2, 44100 * 3, 16) + bytes.fromhex("400eac44000000000000")
|
||
sound = b"\0" * (8 + 44100 * 4 * 3)
|
||
chunks = b"COMM" + struct.pack(">I", len(common)) + common + b"SSND" + struct.pack(">I", len(sound)) + sound
|
||
path.write_bytes(b"FORM" + struct.pack(">I", len(chunks) + 4) + b"AIFF" + chunks)
|
||
return path
|
||
|
||
|
||
def _write_dff(path: Path) -> Path:
|
||
"""
|
||
生成合法DSDIFF声道交织DSD数据及PROP,验证与DSF不同的原生ID3容器。
|
||
"""
|
||
def chunk(name: bytes, data: bytes) -> bytes:
|
||
"""
|
||
DSDIFF块以64位大端长度计数,奇数字节补齐不计入内容长度。
|
||
"""
|
||
return name + struct.pack(">Q", len(data)) + data + b"\0" * (len(data) % 2)
|
||
|
||
properties = b"SND " + chunk(b"FS ", struct.pack(">I", 2822400))
|
||
properties += chunk(b"CHNL", struct.pack(">H", 2) + b"SLFTSRGT") + chunk(b"CMPR", b"DSD \x00")
|
||
content = b"DSD " + chunk(b"FVER", struct.pack(">I", 0x01050000)) + chunk(b"PROP", properties)
|
||
content += chunk(b"DSD ", b"\x69" * (2822400 * 3 * 2 // 8))
|
||
path.write_bytes(chunk(b"FRM8", content))
|
||
return path
|
||
|
||
|
||
@pytest.fixture
|
||
def flac_path(tmp_path):
|
||
"""
|
||
复制无标签的三秒静音 FLAC,所有标签写入仅作用于测试临时文件。
|
||
"""
|
||
path = tmp_path / "03.flac"
|
||
shutil.copyfile(Path(__file__).parent / "fixtures/audio/silence.flac", path)
|
||
return path
|
||
|
||
|
||
@pytest.mark.parametrize("container", ["wav", "flac"])
|
||
def test_untagged_audio_keeps_stream_and_path_evidence(container, tmp_path, flac_path):
|
||
"""
|
||
没有标签仍能读取时长和音质,并用路径补齐曲序而不改动文件。
|
||
"""
|
||
path = _write_wave(tmp_path / "03.wav") if container == "wav" else flac_path
|
||
before = hashlib.sha256(path.read_bytes()).digest()
|
||
|
||
merged, tags, filename = AudioMetadataHelper.read_evidence(path)
|
||
|
||
assert tags is not None
|
||
assert tags.title is None
|
||
assert tags.duration == merged.duration == 3
|
||
assert merged.sample_rate == 44100
|
||
assert merged.bit_depth == 16
|
||
assert merged.track_number == filename.track_number == 3
|
||
assert hashlib.sha256(path.read_bytes()).digest() == before
|
||
|
||
|
||
@pytest.mark.parametrize("container", ["wav", "dsf", "aiff", "dff"])
|
||
def test_raw_id3_container_reads_track_identity(container, tmp_path):
|
||
"""
|
||
WAV/DSF/AIFF/DSDIFF原生ID3帧能提供曲目字段及Recording身份。
|
||
"""
|
||
path = tmp_path / f"03.{container}"
|
||
factory = {"wav": _write_wave, "dsf": _write_dsf, "aiff": _write_aiff, "dff": _write_dff}
|
||
audio = MutagenFile(factory[container](path))
|
||
audio.add_tags()
|
||
for frame in (
|
||
TIT2(encoding=3, text=["晴天"]),
|
||
TPE1(encoding=3, text=["周杰伦", "嘉宾"]),
|
||
TALB(encoding=3, text=["叶惠美"]),
|
||
TPE2(encoding=3, text=["周杰伦"]),
|
||
TDRC(encoding=3, text=["2003-07-31"]),
|
||
TRCK(encoding=3, text=["3/11"]),
|
||
TPOS(encoding=3, text=["1/2"]),
|
||
TSRC(encoding=3, text=["USQX91300105"]),
|
||
TXXX(encoding=3, desc="VERSION", text=["Live"]),
|
||
UFID(owner="http://musicbrainz.org", data=RECORDING_ID.encode("ascii")),
|
||
):
|
||
audio.tags.add(frame)
|
||
audio.save()
|
||
before = path.read_bytes()
|
||
|
||
meta = AudioMetadataHelper.read_tags(path)
|
||
|
||
assert meta.title == "晴天"
|
||
assert meta.artists == ["周杰伦", "嘉宾"]
|
||
assert meta.album == "叶惠美"
|
||
assert meta.album_artist == "周杰伦"
|
||
assert meta.year == 2003
|
||
assert (meta.track_number, meta.total_tracks) == (3, 11)
|
||
assert (meta.disc_number, meta.total_discs) == (1, 2)
|
||
assert meta.isrc == "USQX91300105"
|
||
assert meta.version == "Live"
|
||
assert meta.media_id == RECORDING_ID
|
||
assert meta.media_source == "musicbrainz"
|
||
assert path.read_bytes() == before
|
||
|
||
|
||
@pytest.mark.parametrize("track_total,disc_total", [
|
||
("TRACKTOTAL", "DISCTOTAL"), ("TOTALTRACKS", "TOTALDISCS"),
|
||
])
|
||
def test_flac_reads_separate_track_and_disc_totals(flac_path, track_total, disc_total):
|
||
"""
|
||
Vorbis 的独立总数字段与组合编号拥有同等语义。
|
||
"""
|
||
audio = FLAC(flac_path)
|
||
audio.update({"title": ["晴天"], "tracknumber": ["3"], "discnumber": ["1"],
|
||
track_total: ["11"], disc_total: ["2"]})
|
||
audio.save()
|
||
|
||
meta = AudioMetadataHelper.read_tags(flac_path)
|
||
|
||
assert (meta.track_number, meta.total_tracks) == (3, 11)
|
||
assert (meta.disc_number, meta.total_discs) == (1, 2)
|
||
|
||
|
||
@pytest.mark.parametrize("position,expected", [("3/11", (3, 11)), ("3/0", (3, 12)),
|
||
("0/0", (None, 12)), ("bad", (None, 12))])
|
||
def test_combined_position_prefers_valid_total(flac_path, position, expected):
|
||
"""
|
||
有效组合总数优先,零或坏编号不能污染真实的独立总数。
|
||
"""
|
||
audio = FLAC(flac_path)
|
||
audio.update({"tracknumber": [position], "tracktotal": ["12"]})
|
||
audio.save()
|
||
|
||
meta = AudioMetadataHelper.read_tags(flac_path)
|
||
|
||
assert (meta.track_number, meta.total_tracks) == expected
|
||
|
||
|
||
def test_id3_release_track_id_is_not_recording_identity(tmp_path):
|
||
"""
|
||
发行曲目 ID 不能被当作 Recording ID 传入指纹和单曲识别。
|
||
"""
|
||
path = _write_wave(tmp_path / "03.wav")
|
||
audio = WAVE(path)
|
||
audio.add_tags()
|
||
audio.tags.add(TIT2(encoding=3, text=["晴天"]))
|
||
audio.tags.add(TXXX(encoding=3, desc="MusicBrainz Release Track Id", text=[RECORDING_ID]))
|
||
audio.tags.add(UFID(owner="http://musicbrainz.org", data=b"\xffinvalid"))
|
||
audio.save()
|
||
|
||
meta = AudioMetadataHelper.read_tags(path)
|
||
|
||
assert meta.title == "晴天"
|
||
assert meta.media_id is None
|
||
assert meta.media_source is None
|