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>
96 lines
3.2 KiB
Python
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()
|