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).
223 lines
8.3 KiB
Python
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()
|