soulsync/tests/test_art_preservation.py
BoulderBadgeDad 3dfec8a157 Fix #764: import no longer destroys embedded cover art
enhance_file_metadata rebuilds tags from scratch: for FLAC it calls
clear_pictures(), for MP3/MP4 it clears the whole tag block — and it does
this UP FRONT, then saves the file, long before it tries to fetch and embed
the replacement art. So every way the re-embed could come up empty left the
file saved with the original art destroyed and nothing put back:

  - extract_source_metadata returns nothing  -> early save, no embed
  - no album-art URL / art download fails / rejected by the min-size guard
    -> embed_album_art_metadata returns early without adding a picture
  - art embedding disabled in config         -> embed skipped entirely
  - embed raises mid-enrichment               -> file left cleared on disk

This is the "cover art gets corrupted/destroyed during import" half of #764
(continuation of #755); distinct from #750's truncated-cache DISPLAY bug.

Fix: new core/metadata/art_preservation.py snapshots the existing art
(the live Picture / APIC / MP4Cover objects, so they re-apply verbatim)
BEFORE the clear, and restores it before each save IFF the file currently
has none. Wired into all three exit paths in enhance_file_metadata
(no-metadata early return, the final save, and the except handler). The
restore is a strict no-op when art is already present, so the happy path —
new art embedded — is byte-for-byte unchanged: it never clobbers or
duplicates a freshly-embedded cover. embed_album_art_metadata now returns a
bool so the intent (embedded / didn't) is explicit.

Tests:
- tests/test_art_preservation.py (5) — snapshot/restore round-trips through
  real mutagen FLAC + ID3 objects; restore no-ops when new art is present.
- tests/test_enrichment_art_preservation.py (4) — runs the REAL
  enhance_file_metadata over a real FLAC with embedded art and asserts the
  art survives on disk for missing-metadata / failed-embed / embed-raises,
  and is correctly REPLACED (exactly one picture, new bytes) on success.
1019 tests pass across the metadata/enrichment/imports/acoustid suites.
2026-06-02 08:40:05 -07:00

179 lines
5.7 KiB
Python

"""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"