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.
SoulSync/tests/test_art_preservation.py

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"