Files
lintunes/lintunes/lastfm.py
T
trav 5f917eb68c 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).
2026-07-02 11:36:11 -04:00

223 lines
8.3 KiB
Python

"""Last.fm scrobbling over the plain web API (no extra deps beyond requests).
Auth: auth.getMobileSession with username/password + the user's own API
key/secret (register at https://www.last.fm/api/account/create). Only the
returned session key is persisted, never the password.
Scrobbles happen when a track plays to the very end (the player's
track_finished signal). Failures are queued to <data_dir>/scrobble_queue.json
and retried on startup and after the next successful call. All network I/O
runs on background threads.
"""
import hashlib
import json
import threading
import time
from pathlib import Path
from PyQt6.QtCore import QObject, pyqtSignal
API_URL = "https://ws.audioscrobbler.com/2.0/"
QUEUE_FILE = "scrobble_queue.json"
QUEUE_LIMIT = 500
def sign(params: dict, secret: str) -> str:
"""Last.fm api_sig: md5 of concatenated key+value pairs (sorted by key,
excluding 'format'/'callback') followed by the shared secret."""
pieces = [f"{k}{params[k]}" for k in sorted(params)
if k not in ("format", "callback")]
raw = "".join(pieces) + secret
return hashlib.md5(raw.encode("utf-8")).hexdigest()
class LastFm(QObject):
login_finished = pyqtSignal(bool, str) # success, message
status_message = pyqtSignal(str)
# Worker thread → GUI thread: prefs must only be touched from the GUI
# thread (writing them emits prefs.changed, which drives theme updates).
_session_ready = pyqtSignal(str, str, str, str) # key, secret, user, session
def __init__(self, prefs, data_dir: Path, parent=None):
super().__init__(parent)
self._prefs = prefs
self._queue_path = Path(data_dir) / QUEUE_FILE
self._queue_lock = threading.Lock()
self._session_ready.connect(self._store_session)
# Retry anything left over from previous runs
if self.is_logged_in() and self._load_queue():
self._spawn(self._flush_queue)
# ---- state ----
def is_logged_in(self) -> bool:
lf = self._prefs.lastfm
return bool(lf.get("session_key") and lf.get("api_key")
and lf.get("api_secret"))
def scrobbling_enabled(self) -> bool:
return self.is_logged_in() and self._prefs.lastfm.get("scrobble_enabled")
# ---- auth ----
def login(self, api_key: str, api_secret: str, username: str, password: str):
def work():
try:
data = self._call(api_key, api_secret, "auth.getMobileSession",
{"username": username, "password": password},
session_key=None)
session = data["session"]
self._session_ready.emit(
api_key, api_secret, session.get("name", username),
session["key"])
except Exception as e:
self.login_finished.emit(False, f"Login failed: {e}")
self._spawn(work)
def _store_session(self, api_key, api_secret, username, session_key):
self._prefs.update_lastfm(
api_key=api_key, api_secret=api_secret,
username=username, session_key=session_key)
self.login_finished.emit(True, f"Logged in as {username}")
def logout(self):
self._prefs.update_lastfm(session_key="", scrobble_enabled=False)
# ---- playback hooks ----
def handle_track_started(self, track):
if track is None or not self.scrobbling_enabled() or not track.artist:
return
params = {"artist": track.artist, "track": track.name}
if track.album:
params["album"] = track.album
if track.total_time:
params["duration"] = str(track.total_time // 1000)
self._spawn(lambda: self._try_call("track.updateNowPlaying", params))
def handle_track_finished(self, track):
if track is None or not self.scrobbling_enabled() or not track.artist:
return
duration_s = (track.total_time or 0) // 1000
entry = {
"artist": track.artist,
"track": track.name,
"album": track.album or "",
"timestamp": int(time.time()) - duration_s,
"duration": duration_s,
}
self._spawn(lambda: self._scrobble_entry(entry))
# ---- internals ----
def _scrobble_entry(self, entry: dict):
params = {"artist": entry["artist"], "track": entry["track"],
"timestamp": str(entry["timestamp"])}
if entry.get("album"):
params["album"] = entry["album"]
if entry.get("duration"):
params["duration"] = str(entry["duration"])
try:
self._call_with_session("track.scrobble", params)
self.status_message.emit(
f"Scrobbled: {entry['artist']} — {entry['track']}")
self._flush_queue()
except Exception as e:
self._enqueue(entry)
self.status_message.emit(f"Scrobble queued (offline?): {e}")
def _flush_queue(self):
# Take the whole queue under the lock; entries that still fail are
# re-queued afterwards so a concurrent scrobble can't be clobbered by
# our read-modify-write.
with self._queue_lock:
queue = self._load_queue()
self._save_queue([])
if not queue:
return
remaining = []
for entry in queue:
try:
params = {"artist": entry["artist"], "track": entry["track"],
"timestamp": str(entry["timestamp"])}
if entry.get("album"):
params["album"] = entry["album"]
self._call_with_session("track.scrobble", params)
except Exception:
remaining.append(entry)
if remaining:
with self._queue_lock:
merged = remaining + self._load_queue()
self._save_queue(merged[-QUEUE_LIMIT:])
flushed = len(queue) - len(remaining)
if flushed:
self.status_message.emit(f"Sent {flushed} queued scrobble(s)")
def _try_call(self, method: str, params: dict):
try:
self._call_with_session(method, params)
except Exception:
pass # now-playing updates are best-effort
def _call_with_session(self, method: str, params: dict) -> dict:
lf = self._prefs.lastfm
return self._call(lf["api_key"], lf["api_secret"], method, params,
session_key=lf["session_key"])
@staticmethod
def _call(api_key: str, secret: str, method: str, params: dict,
session_key: str | None) -> dict:
import requests
payload = dict(params)
payload["method"] = method
payload["api_key"] = api_key
if session_key:
payload["sk"] = session_key
payload["api_sig"] = sign(payload, secret)
payload["format"] = "json"
response = requests.post(API_URL, data=payload, timeout=15)
try:
data = response.json()
except ValueError:
# Non-JSON body (e.g. an HTML 5xx page): surface the HTTP error.
response.raise_for_status()
raise RuntimeError(
f"Unexpected response from last.fm ({response.status_code})")
if "error" in data:
raise RuntimeError(data.get("message", f"error {data['error']}"))
response.raise_for_status()
return data
# ---- offline queue ----
def _enqueue(self, entry: dict):
with self._queue_lock:
queue = self._load_queue()
queue.append(entry)
self._save_queue(queue[-QUEUE_LIMIT:])
def _load_queue(self) -> list:
if not self._queue_path.exists():
return []
try:
with open(self._queue_path, "r", encoding="utf-8") as f:
return json.load(f)
except (ValueError, OSError):
return []
def _save_queue(self, queue: list):
if not queue:
if self._queue_path.exists():
self._queue_path.unlink()
return
self._queue_path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._queue_path.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as f:
json.dump(queue, f, indent=2)
tmp.replace(self._queue_path)
@staticmethod
def _spawn(fn):
threading.Thread(target=fn, daemon=True).start()