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:
@@ -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]
|
||||
Reference in New Issue
Block a user