v0.23.0: covers for the whole album

- Multi-select Get Info keeps the art square; a pasted cover goes into
  every selected track (shared cover shown, else "mixed artwork").
- Paste Artwork button + Ctrl+V anywhere outside a text field; a caption
  says what happened. Clipboard reads that don't decode are retried and
  never staged; every attempt is logged at INFO.
- Album art search queries Deezer alongside iTunes and ranks by album
  match (iTunes has no copy of Digable Planets' Reachin' at all).
- embed_artwork shared by Get Info and Download Album Art; single-track
  Get Info now invalidates the MPRIS art cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 09:38:39 -07:00
co-authored by Claude Opus 5
parent d448f64c2b
commit 3150df0f89
9 changed files with 723 additions and 93 deletions
+279
View File
@@ -0,0 +1,279 @@
"""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]