Files
lintunes/lintunes/art_search.py
T
travandClaude Fable 5 8dc33fa65e Round 17: task batch + cruft sweep (ratings, art download, MPRIS/exit/BT fixes)
Features:
- Rating hover dots: hovering a rating cell shows 5 clickable slots
  (RatingDelegate); click slot k sets k stars, clicking the current count
  clears. Undoable, library-only (ratings never rewrite music files).
- Search bar moved into the Library header strip, right of the Library
  button, so the tracklist top aligns with the playlist tree.
- Right-click "Download Album Art…": iTunes Search API (no key), off-thread
  fetch, preview/confirm dialog with Next Result, embeds via write_artwork
  + size refresh, invalidates the MPRIS art cache.

Fixes:
- MPRIS media keys: PropertiesChanged sent invalidated_properties as "av"
  instead of "as", so gsd-media-keys dropped it and never MRU-bumped
  lintunes (why the play/pause key kept waking stale players). Now an
  explicit QDBusArgument string array; loopback-verified sa{sv}as.
- Exit segfault: ordered Player.shutdown() (stop, clear source, detach
  buffer/audio outputs) from closeEvent/aboutToQuit; scripted run exits 0.
- BT zero-volume after pause/resume: volume re-applied on resume, device
  swap, and BufferedMedia (needs verify on the affected machine).

Cruft sweep:
- Tag writes filtered to EDITABLE_FIELDS; failures logged + surfaced in
  the status bar (was a swallowed print).
- O(n²) import fixed (location index + cached max track id); O(1)
  refresh/reveal via TrackTableModel row index.
- lastfm: scrobble-queue thread lock, login prefs write marshalled to the
  GUI thread, JSON/raise_for_status order fixed.
- Shared read_json/write_json; TableSettingsMixin dedupes view settings.

TASKS.md rewritten as a resumable board; tests in tests/test_round17.py
(251 total pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:36:11 -04:00

96 lines
3.2 KiB
Python

"""Album-art lookup via the iTunes Search API (no API key required).
Pure parsing/URL helpers are separated from the network calls so they can be
tested offline; ``AlbumArtFetcher`` runs the whole search+download on a
daemon thread (the lastfm pattern) and reports back over a Qt signal, which
is delivered queued on the GUI thread.
"""
import threading
from dataclasses import dataclass
from PyQt6.QtCore import QObject, pyqtSignal
ITUNES_SEARCH_URL = "https://itunes.apple.com/search"
ART_SIZE = 600 # px; the API hands out 100x100 URLs that scale on request
TIMEOUT_S = 15
@dataclass
class ArtCandidate:
artist: str
album: str
art_url: str # already upgraded to ART_SIZE
def upgrade_artwork_url(url: str, size: int = ART_SIZE) -> str:
"""The API returns .../100x100bb.jpg thumbnails; Apple's image server
serves the same asset at (almost) any requested size."""
return url.replace("100x100", f"{size}x{size}")
def parse_results(payload: dict) -> list[ArtCandidate]:
candidates = []
for item in payload.get("results", []):
url = item.get("artworkUrl100")
if not url:
continue
candidates.append(ArtCandidate(
artist=item.get("artistName", ""),
album=item.get("collectionName", ""),
art_url=upgrade_artwork_url(url),
))
return candidates
def search_album_art(artist: str, album: str, limit: int = 5) -> list[ArtCandidate]:
import requests
response = requests.get(
ITUNES_SEARCH_URL,
params={"term": f"{artist} {album}".strip(), "entity": "album",
"media": "music", "limit": limit},
timeout=TIMEOUT_S,
)
response.raise_for_status()
return parse_results(response.json())
def fetch_image(url: str) -> tuple[bytes, str]:
"""Download an image; returns (bytes, mime type)."""
import requests
response = requests.get(url, timeout=TIMEOUT_S)
response.raise_for_status()
mime = response.headers.get("Content-Type", "image/jpeg").split(";")[0]
return response.content, mime or "image/jpeg"
class AlbumArtFetcher(QObject):
"""One search-or-download running off the GUI thread.
``search_finished`` carries {"candidates": [ArtCandidate, ...]} or
{"error": str}; ``image_finished`` carries {"candidate": ArtCandidate,
"image": bytes, "mime": str} or {"error": str}.
"""
search_finished = pyqtSignal(object)
image_finished = pyqtSignal(object)
def search(self, artist: str, album: str):
def work():
try:
candidates = search_album_art(artist, album)
self.search_finished.emit({"candidates": candidates})
except Exception as e:
self.search_finished.emit({"error": str(e)})
threading.Thread(target=work, daemon=True).start()
def fetch(self, candidate: ArtCandidate):
def work():
try:
data, mime = fetch_image(candidate.art_url)
self.image_finished.emit(
{"candidate": candidate, "image": data, "mime": mime})
except Exception as e:
self.image_finished.emit({"error": str(e)})
threading.Thread(target=work, daemon=True).start()