Files
lintunes/tests/test_round60.py
T
travandClaude Opus 5 3150df0f89 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>
2026-09-22 09:38:39 -07:00

280 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]