mirror of https://github.com/Nezreka/SoulSync.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
180 lines
5.7 KiB
180 lines
5.7 KiB
"""Tests for embedded-cover-art preservation across the enrichment rewrite.
|
|
|
|
Regression for #764 (continuation of #755): importing a file destroyed its
|
|
embedded album art whenever the re-embed step couldn't produce new art
|
|
(no source metadata, no art URL, download failed, rejected by the min-size
|
|
guard, or art embedding disabled). ``enhance_file_metadata`` clears pictures
|
|
up front and saves regardless; these helpers snapshot the art first and put
|
|
it back iff the file would otherwise be saved with none.
|
|
|
|
Uses real mutagen objects (a minimal valid FLAC + a minimal MP3) so the
|
|
snapshot/restore round-trips through the actual Picture/APIC APIs the
|
|
enricher uses — not a mock of them.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
import pytest
|
|
|
|
mutagen = pytest.importorskip("mutagen")
|
|
|
|
from mutagen.flac import FLAC, Picture # noqa: E402
|
|
from mutagen.id3 import APIC, ID3, TIT2 # noqa: E402
|
|
|
|
from core.metadata.common import get_mutagen_symbols # noqa: E402
|
|
from core.metadata.art_preservation import ( # noqa: E402
|
|
has_embedded_art,
|
|
restore_embedded_art,
|
|
snapshot_embedded_art,
|
|
)
|
|
|
|
SYMBOLS = get_mutagen_symbols()
|
|
|
|
# 1x1 PNG — smallest valid image bytes for a real Picture/APIC payload.
|
|
_PNG = (
|
|
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
|
|
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00"
|
|
b"\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
|
)
|
|
|
|
|
|
def _minimal_flac_bytes() -> bytes:
|
|
# 4-byte magic + last STREAMINFO block (34 bytes). Mirrors the fixture
|
|
# used by tests/test_tag_writer_multi_artist.py.
|
|
return (
|
|
b"fLaC"
|
|
+ b"\x80\x00\x00\x22"
|
|
+ b"\x00\x10\x00\x10"
|
|
+ b"\x00\x00\x00\x00\x00\x00"
|
|
+ b"\x0a\xc4\x42\xf0\x00\x00\x00\x00"
|
|
+ b"\x00" * 16
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def flac_with_art():
|
|
fd, path = tempfile.mkstemp(suffix=".flac")
|
|
os.close(fd)
|
|
with open(path, "wb") as f:
|
|
f.write(_minimal_flac_bytes())
|
|
audio = FLAC(path)
|
|
pic = Picture()
|
|
pic.data = _PNG
|
|
pic.type = 3
|
|
pic.mime = "image/png"
|
|
pic.width = 1
|
|
pic.height = 1
|
|
pic.depth = 24
|
|
audio.add_picture(pic)
|
|
audio.save()
|
|
yield path
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class _ID3Holder:
|
|
"""Stand-in for an mutagen MP3 object: exposes a real ``ID3`` tag block
|
|
(the only thing the snapshot/restore helpers touch) without needing a
|
|
syncable MPEG frame on disk. ``__setitem__`` mirrors mutagen's mapping
|
|
sugar used by the MP4 restore branch — unused here but kept faithful."""
|
|
|
|
def __init__(self):
|
|
self.tags = ID3()
|
|
self.tags.add(TIT2(encoding=3, text=["original"]))
|
|
self.tags.add(APIC(encoding=3, mime="image/png", type=3, desc="Cover", data=_PNG))
|
|
|
|
def __setitem__(self, key, value):
|
|
self.tags[key] = value
|
|
|
|
|
|
@pytest.fixture
|
|
def mp3_with_art():
|
|
return _ID3Holder()
|
|
|
|
|
|
# ── FLAC ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_flac_art_restored_after_clear(flac_with_art):
|
|
audio = FLAC(flac_with_art)
|
|
assert has_embedded_art(audio, SYMBOLS)
|
|
|
|
snap = snapshot_embedded_art(audio, SYMBOLS)
|
|
assert snap # something captured
|
|
|
|
# Simulate the enrichment rewrite: nuke the art, fail to re-embed.
|
|
audio.clear_pictures()
|
|
assert not has_embedded_art(audio, SYMBOLS)
|
|
|
|
restored = restore_embedded_art(audio, SYMBOLS, snap)
|
|
assert restored is True
|
|
assert has_embedded_art(audio, SYMBOLS)
|
|
assert audio.pictures[0].data == _PNG
|
|
|
|
|
|
def test_flac_restore_is_noop_when_new_art_present(flac_with_art):
|
|
# Happy path: re-embed succeeded, so restore must NOT touch the file.
|
|
audio = FLAC(flac_with_art)
|
|
snap = snapshot_embedded_art(audio, SYMBOLS)
|
|
|
|
audio.clear_pictures()
|
|
new = Picture()
|
|
new.data = _PNG + b"NEWART"
|
|
new.type = 3
|
|
new.mime = "image/png"
|
|
audio.add_picture(new)
|
|
|
|
restored = restore_embedded_art(audio, SYMBOLS, snap)
|
|
assert restored is False
|
|
assert len(audio.pictures) == 1
|
|
assert audio.pictures[0].data == _PNG + b"NEWART" # not clobbered/duplicated
|
|
|
|
|
|
def test_flac_no_art_snapshot_empty():
|
|
fd, path = tempfile.mkstemp(suffix=".flac")
|
|
os.close(fd)
|
|
try:
|
|
with open(path, "wb") as f:
|
|
f.write(_minimal_flac_bytes())
|
|
audio = FLAC(path)
|
|
assert snapshot_embedded_art(audio, SYMBOLS) == []
|
|
# Restoring an empty snapshot is a no-op.
|
|
assert restore_embedded_art(audio, SYMBOLS, []) is False
|
|
finally:
|
|
os.remove(path)
|
|
|
|
|
|
# ── MP3 / ID3 ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_mp3_apic_restored_after_tags_cleared(mp3_with_art):
|
|
audio = mp3_with_art
|
|
assert has_embedded_art(audio, SYMBOLS)
|
|
snap = snapshot_embedded_art(audio, SYMBOLS)
|
|
assert snap
|
|
|
|
# The enricher does audio_file.tags.clear() then rewrites tags.
|
|
audio.tags.clear()
|
|
assert not has_embedded_art(audio, SYMBOLS)
|
|
|
|
restored = restore_embedded_art(audio, SYMBOLS, snap)
|
|
assert restored is True
|
|
apics = audio.tags.getall("APIC")
|
|
assert apics and apics[0].data == _PNG
|
|
|
|
|
|
def test_mp3_restore_noop_when_new_apic_present(mp3_with_art):
|
|
audio = mp3_with_art
|
|
snap = snapshot_embedded_art(audio, SYMBOLS)
|
|
audio.tags.clear()
|
|
audio.tags.add(APIC(encoding=3, mime="image/png", type=3, desc="Cover", data=_PNG + b"NEW"))
|
|
|
|
assert restore_embedded_art(audio, SYMBOLS, snap) is False
|
|
apics = audio.tags.getall("APIC")
|
|
assert len(apics) == 1 and apics[0].data == _PNG + b"NEW"
|