"""Round 60: album art for a whole selection, a paste that never stages a broken image, and Deezer as a second art-search source.""" import shutil import pytest from PyQt6.QtCore import QMimeData, QUrl from lintunes import art_search, tagging from lintunes.art_search import ( ArtCandidate, _simplify_album, parse_deezer_results, rank_candidates, search_album_art, ) from lintunes.library_manager import LibraryManager from lintunes.models import Track from lintunes.models.library import Library def _manager(tmp_path, tracks=()): library = Library() for track in tracks: library.tracks[track.track_id] = track return LibraryManager(library, tmp_path) def _copies(mp3_file, tmp_path, n): tracks = [] for i in range(1, n + 1): path = tmp_path / f"song{i}.mp3" shutil.copyfile(mp3_file, path) tracks.append(Track(track_id=i, name=f"Song {i}", artist="A", album="B", location=str(path), size=1)) return tracks # ---- search: Deezer + ranking ---- REACHIN = "Reachin' (A New Refutation of Time and Space)" class TestDeezer: def test_parse_prefers_xl_cover(self): payload = {"data": [ {"title": REACHIN, "artist": {"name": "Digable Planets"}, "cover_xl": "https://x/1000.jpg", "cover_big": "https://x/500.jpg"}, {"title": "No Cover", "artist": {"name": "Z"}}, ]} [c] = parse_deezer_results(payload) assert c.source == "Deezer" assert c.art_url == "https://x/1000.jpg" assert c.artist == "Digable Planets" def test_simplify_album(self): assert _simplify_album(REACHIN) == "Reachin'" assert _simplify_album("Reachin’ (A New Refutation)") == "Reachin'" assert _simplify_album("Rumours (Deluxe Edition) [Remastered]") == "Rumours" assert _simplify_album("Abbey Road - Remastered 2019") == "Abbey Road" assert _simplify_album("(What's the Story) Morning Glory?") \ == "(What's the Story) Morning Glory?" def test_right_album_outranks_right_artist(self): stray = ArtCandidate("Digable Planets", "Blowout Comb", "u1", "iTunes") real = ArtCandidate("Digable Planets", "Reachin’ (A New Refutation Of Time And Space)", "u2", "Deezer") ranked = rank_candidates([stray, real], "Digable Planets", REACHIN) assert ranked[0] is real def test_ranking_drops_same_source_duplicates_keeps_both_sources(self): a = ArtCandidate("X", "Album", "u1", "iTunes") b = ArtCandidate("x", "album", "u2", "iTunes") c = ArtCandidate("X", "Album", "u3", "Deezer") ranked = rank_candidates([a, b, c], "X", "Album") assert [r.art_url for r in ranked] == ["u1", "u3"] class _Response: def __init__(self, payload): self._payload = payload def raise_for_status(self): pass def json(self): return self._payload def _fake_get(itunes=None, deezer=None): """requests.get stand-in: a payload dict, or an exception to raise.""" def get(url, params=None, timeout=None): answer = itunes if "itunes" in url else deezer if isinstance(answer, Exception): raise answer return _Response(answer if answer is not None else {}) return get DEEZER_REACHIN = {"data": [{"title": REACHIN, "artist": {"name": "Digable Planets"}, "cover_xl": "https://dz/1000.jpg"}]} ITUNES_STRAY = {"results": [{"artistName": "Digable Planets", "collectionName": "Blowout Comb", "artworkUrl100": "https://it/100x100bb.jpg"}]} class TestSearchAlbumArt: def test_deezer_only_hit_is_found_and_first(self, monkeypatch): import requests monkeypatch.setattr(requests, "get", _fake_get(ITUNES_STRAY, DEEZER_REACHIN)) found = search_album_art("Digable Planets", REACHIN) assert found[0].source == "Deezer" assert len(found) == 2 def test_one_source_failing_is_not_an_error(self, monkeypatch): import requests monkeypatch.setattr(requests, "get", _fake_get(OSError("offline"), DEEZER_REACHIN)) assert [c.source for c in search_album_art("Digable Planets", REACHIN)] \ == ["Deezer"] def test_both_failing_raises(self, monkeypatch): import requests monkeypatch.setattr(requests, "get", _fake_get(OSError("a"), OSError("b"))) with pytest.raises(OSError): search_album_art("A", "B") def test_both_empty_is_empty(self, monkeypatch): import requests monkeypatch.setattr(requests, "get", _fake_get({}, {})) assert search_album_art("A", "B") == [] def test_itunes_retries_with_simplified_title(self, monkeypatch): terms = [] def query(term, limit): terms.append(term) return [] monkeypatch.setattr(art_search, "_itunes_query", query) art_search.search_itunes("Digable Planets", REACHIN) assert terms == [f"Digable Planets {REACHIN}", "Digable Planets Reachin'"] # ---- paste: never stage what doesn't decode ---- def _png_bytes(): from PyQt6.QtCore import QBuffer, QIODevice from PyQt6.QtGui import QImage, QColor image = QImage(8, 8, QImage.Format.Format_RGB32) image.fill(QColor("red")) buffer = QBuffer() buffer.open(QIODevice.OpenModeFlag.WriteOnly) image.save(buffer, "PNG") return bytes(buffer.data()) class TestPaste: def test_truncated_png_is_rejected(self, qapp): from lintunes.gui.info_dialog import clipboard_image mime = QMimeData() mime.setData("image/png", _png_bytes()[:20]) assert clipboard_image(mime) is None def test_good_png_is_accepted_raw(self, qapp): from lintunes.gui.info_dialog import clipboard_image mime = QMimeData() png = _png_bytes() mime.setData("image/png", png) assert clipboard_image(mime) == (png, "image/png") def test_local_image_file_url(self, qapp, tmp_path): from lintunes.gui.info_dialog import clipboard_image path = tmp_path / "cover.png" path.write_bytes(_png_bytes()) mime = QMimeData() mime.setUrls([QUrl.fromLocalFile(str(path))]) assert clipboard_image(mime)[1] == "image/png" def test_bad_read_retries_then_stages_good(self, qapp, monkeypatch): from lintunes.gui import info_dialog square = info_dialog.ArtSquare() staged, hints = [], [] square.art_staged.connect(lambda b, m: staged.append(b)) square.hint_changed.connect(hints.append) bad = QMimeData() bad.setData("image/png", b"\x89PNG\r\n") good = QMimeData() good.setData("image/png", _png_bytes()) reads = iter([bad, good]) class Clipboard: def mimeData(self): return next(reads) monkeypatch.setattr(info_dialog.QApplication, "clipboard", staticmethod(lambda: Clipboard())) square.paste_from_clipboard() assert staged == [] assert hints[-1] == "Reading the clipboard…" square._retry_timer.stop() square._try_paste() # what the timer would do assert staged == [_png_bytes()] assert hints[-1] == info_dialog.HINT_STAGED def test_text_clipboard_says_so_without_retrying(self, qapp, monkeypatch): from lintunes.gui import info_dialog square = info_dialog.ArtSquare() hints = [] square.hint_changed.connect(hints.append) text = QMimeData() text.setText("hello") monkeypatch.setattr(info_dialog.QApplication, "clipboard", staticmethod(lambda: type( "C", (), {"mimeData": lambda self: text})())) square.paste_from_clipboard() assert hints == ["The clipboard doesn't hold an image"] assert not square._retry_timer.isActive() def test_stage_backstop_rejects_garbage(self, qapp): from lintunes.gui.info_dialog import ArtSquare square = ArtSquare() staged = [] square.art_staged.connect(lambda b, m: staged.append(b)) square._stage(b"not an image", "image/jpeg") assert staged == [] # ---- multi-track Get Info carries artwork ---- class TestMultiArt: def test_square_visible_and_paste_embeds_everywhere( self, qapp, tmp_path, mp3_file, jpeg_bytes): from lintunes.gui.info_dialog import InfoDialog tracks = _copies(mp3_file, tmp_path, 3) manager = _manager(tmp_path, tracks) dialog = InfoDialog(manager, [1, 2, 3]) assert not dialog._art_square.isHidden() assert "mixed" in dialog._art_square.text() dialog._art_square._stage(jpeg_bytes, "image/jpeg") dialog._on_ok() for track in tracks: assert tagging.read_embedded_artwork(track.location) == jpeg_bytes assert track.size > 1 def test_shared_cover_is_shown(self, qapp, tmp_path, mp3_file, jpeg_bytes): from lintunes.gui.info_dialog import InfoDialog tracks = _copies(mp3_file, tmp_path, 2) for track in tracks: tagging.write_artwork(track.location, jpeg_bytes, "image/jpeg") dialog = InfoDialog(_manager(tmp_path, tracks), [1, 2]) assert dialog._art_square.pixmap() is not None assert not dialog._art_square.pixmap().isNull() def test_untouched_art_writes_nothing(self, qapp, tmp_path, mp3_file): from lintunes.gui.info_dialog import InfoDialog tracks = _copies(mp3_file, tmp_path, 2) before = [open(t.location, "rb").read() for t in tracks] InfoDialog(_manager(tmp_path, tracks), [1, 2])._on_ok() assert [open(t.location, "rb").read() for t in tracks] == before assert all(t.size == 1 for t in tracks) class TestEmbedHelper: def test_reports_failures_and_invalidates_mpris( self, qapp, tmp_path, mp3_file, jpeg_bytes, monkeypatch): from lintunes.gui import art_ops invalidated = [] monkeypatch.setattr(art_ops.mpris, "invalidate_artwork", invalidated.append) [good] = _copies(mp3_file, tmp_path, 1) broken = Track(track_id=2, name="Broken", location=str(tmp_path / "missing.mp3")) nofile = Track(track_id=3, name="No File", location="") manager = _manager(tmp_path, [good, broken, nofile]) done, failures = art_ops.embed_artwork( manager, [good, broken, nofile], jpeg_bytes, "image/jpeg") assert done == 1 assert len(failures) == 1 and failures[0].startswith("Broken:") assert invalidated == [good]