feat: ingest a Last.fm scrobble history into a local store
Build and publish container / build (push) Failing after 2m16s

First stage of a curation tool for the music library that music-mirror
mirrors. Before anything can build playlists or decide what has gone cold,
there has to be a local, queryable record of what is actually played; an API
call per question does not scale to a library-sized analysis.

Ingest is in two halves. A catch-up fetches everything scrobbled since the
newest scrobble held, and a backfill walks the history backwards until it runs
out. Both take their bounds from the database rather than from a saved cursor,
so an interrupted run resumes from what it actually has, and both windows are
bounded at each end so paging cannot shift under the fetch while new scrobbles
arrive mid-run.

Scrobbles carry no identifier, so the primary key is timestamp, artist and
track. Two plays of one track in the same second collapse into a single row:
they are indistinguishable in the data, and a surrogate key would make
re-ingest non-idempotent, which is the worse trade.

Three API behaviours are handled explicitly because each fails silently:
the currently-playing track arrives with no timestamp and would be re-ingested
on every pass; a lone result is returned as a bare object rather than a
one-item list; and MBIDs are empty strings rather than absent when unknown,
which would later look like a usable join key.

Retries cover the rate limit and the transient backend errors with an
exponential backoff. An invalid or suspended key fails immediately.

The report exists to surface one number before the next stage is built: the
share of scrobbles carrying a MusicBrainz recording id. Lidarr exposes the same
identifier per track, so those can be joined exactly and the rest must go
through name matching. That percentage bounds how far the matcher can be
trusted.

No runtime dependencies, and the tests run against a fake transport that
reproduces the service's paging and response shapes, so they need neither
network nor credentials.
This commit is contained in:
Emma Thorpe
2026-08-24 12:03:08 +01:00
commit 18f05d3d55
11 changed files with 1458 additions and 0 deletions
+643
View File
@@ -0,0 +1,643 @@
"""Build a local record of what actually gets listened to.
Ingests a Last.fm scrobble history into SQLite and keeps it current, so that
the later stages -- building playlists, and deciding what in the library has
gone cold -- can query listening habits locally instead of spending an API call
per question.
The store is derived state. It can be deleted and rebuilt from Last.fm at any
time. Nothing here writes to Last.fm, and nothing here touches the music
library or Lidarr.
Ingest is in two halves. A backfill walks the history backwards until it runs
out, and a catch-up fetches everything scrobbled since the newest scrobble
already held. Both take their bounds from the database rather than from a saved
cursor, so an interrupted run resumes from what it actually has.
"""
import argparse
import fcntl
import json
import logging
import os
import re
import signal
import sqlite3
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
logger = logging.getLogger("music-curator")
API_ROOT = "https://ws.audioscrobbler.com/2.0/"
# The documented ceiling for user.getRecentTracks. getLovedTracks does not state
# one; asking for more than it allows is harmless, because paging is driven by
# the totalPages the response reports rather than by arithmetic on this number.
PAGE_SIZE = 200
# Error codes worth another attempt: backend failure, service offline, service
# temporarily unavailable, rate limit. Everything else is a fault in the request
# or the key and will fail again identically.
RETRYABLE_ERRORS = {8, 11, 16, 29}
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
# Last.fm asks for no more than five requests a second averaged over five
# minutes. A full backfill is thousands of requests, so it is worth staying
# well inside that rather than discovering error 29 halfway through.
REQUEST_DELAY_SECONDS = 0.25
BACKOFF_SECONDS = 2.0
BACKOFF_CEILING_SECONDS = 60.0
SCHEMA_VERSION = "1"
SCHEMA = """
-- One row per scrobble. The primary key collapses two plays of the same track
-- in the same second into one: Last.fm gives scrobbles no identifier, so they
-- are genuinely indistinguishable, and one lost play a decade is not worth a
-- surrogate key that would make re-ingest non-idempotent.
CREATE TABLE IF NOT EXISTS scrobble (
uts INTEGER NOT NULL,
artist TEXT NOT NULL,
track TEXT NOT NULL,
album TEXT NOT NULL DEFAULT '',
artist_mbid TEXT,
album_mbid TEXT,
track_mbid TEXT,
PRIMARY KEY (uts, artist, track)
);
CREATE INDEX IF NOT EXISTS scrobble_uts ON scrobble (uts);
CREATE INDEX IF NOT EXISTS scrobble_artist_track ON scrobble (artist, track);
CREATE INDEX IF NOT EXISTS scrobble_track_mbid ON scrobble (track_mbid);
-- Loved tracks are current state, not history: a track can be unloved again.
-- The table is replaced on every pass rather than accumulated.
CREATE TABLE IF NOT EXISTS loved (
artist TEXT NOT NULL,
track TEXT NOT NULL,
track_mbid TEXT,
loved_at INTEGER,
PRIMARY KEY (artist, track)
);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"""
class LastfmError(Exception):
"""A Last.fm request that failed in a way retrying will not fix."""
@dataclass(frozen=True)
class Scrobble:
"""One play, as Last.fm recorded it."""
uts: int
artist: str
track: str
album: str = ""
artist_mbid: str | None = None
album_mbid: str | None = None
track_mbid: str | None = None
def parse_interval(interval):
"""Return seconds for an interval such as ``30m``, ``6h`` or ``90``."""
text = str(interval).strip().lower()
match = re.fullmatch(r"([0-9]+)([smhd]?)", text)
if not match:
raise ValueError(f"unrecognised interval {interval!r}: expected e.g. 45m, 6h, 1d")
value = int(match.group(1))
return value * {"": 1, "s": 1, "m": 60, "h": 3600, "d": 86400}[match.group(2)]
def as_list(value):
"""Return a list for a field Last.fm renders as a bare object when singular."""
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def name_of(value):
"""Return the name out of a Last.fm sub-object.
The same conceptual field is ``#text`` in some responses (recent tracks) and
``name`` in others (loved tracks, and anything fetched with extended=1).
"""
if isinstance(value, dict):
return (value.get("name") or value.get("#text") or "").strip()
return (value or "").strip()
def mbid_of(value):
"""Return an MBID, or None where the field is present but empty.
Last.fm sends an empty string rather than omitting the key, and an empty
string would otherwise look like a usable join key further down the line.
"""
if isinstance(value, dict):
value = value.get("mbid")
value = (value or "").strip()
return value or None
def parse_scrobble(entry):
"""Return a Scrobble for one recent-tracks entry, or None to skip it.
The currently-playing track comes back with no ``date`` at all. Storing it
would mean a scrobble with no timestamp, and it would arrive again on every
pass until the song ended, so it is dropped until it has a real one.
"""
date = entry.get("date") or {}
if "uts" not in date:
return None
artist = name_of(entry.get("artist"))
track = (entry.get("name") or "").strip()
if not artist or not track:
return None
return Scrobble(
uts=int(date["uts"]),
artist=artist,
track=track,
album=name_of(entry.get("album")),
artist_mbid=mbid_of(entry.get("artist")),
album_mbid=mbid_of(entry.get("album")),
track_mbid=mbid_of(entry.get("mbid")),
)
def http_get(url, timeout=30):
"""Fetch a URL and return its body. Replaced in tests."""
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 - fixed https root
return response.read().decode("utf-8")
class Lastfm:
"""Minimal read-only Last.fm client.
Only an API key is needed: none of the methods used here authenticate a
user, so there is no session key, no signing and no secret to hold.
"""
def __init__(
self,
api_key,
delay=REQUEST_DELAY_SECONDS,
attempts=5,
backoff=BACKOFF_SECONDS,
transport=None,
):
self.api_key = api_key
self.delay = delay
self.attempts = attempts
self.backoff = backoff
# Resolved here rather than as a default argument so the tests can
# replace the module-level fetcher.
self.transport = transport or http_get
self._previous_request = None
def call(self, method, params):
"""Return the decoded response for one API method."""
query = {key: value for key, value in params.items() if value is not None}
query.update(method=method, api_key=self.api_key, format="json")
url = f"{API_ROOT}?{urllib.parse.urlencode(query)}"
for attempt in range(1, self.attempts + 1):
self._throttle()
try:
payload = json.loads(self.transport(url))
except urllib.error.HTTPError as error:
if error.code not in RETRYABLE_STATUS:
raise LastfmError(f"{method}: HTTP {error.code}") from error
self._retry_or_raise(method, attempt, f"HTTP {error.code}", error)
continue
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as error:
self._retry_or_raise(method, attempt, str(error), error)
continue
code = payload.get("error")
if code is None:
return payload
detail = f"error {code}: {payload.get('message', '')}".strip()
if code not in RETRYABLE_ERRORS:
raise LastfmError(f"{method}: {detail}")
self._retry_or_raise(method, attempt, detail, None)
raise LastfmError(f"{method}: gave up after {self.attempts} attempts")
def _retry_or_raise(self, method, attempt, detail, cause):
"""Sleep before the next attempt, or give up if this was the last one."""
if attempt >= self.attempts:
raise LastfmError(f"{method}: {detail}") from cause
pause = min(self.backoff * 2 ** (attempt - 1), BACKOFF_CEILING_SECONDS)
logger.warning("%s: %s; retrying in %.0fs", method, detail, pause)
time.sleep(pause)
def _throttle(self):
"""Keep consecutive requests at least `delay` apart."""
if self.delay <= 0:
return
if self._previous_request is not None:
waited = time.monotonic() - self._previous_request
if waited < self.delay:
time.sleep(self.delay - waited)
self._previous_request = time.monotonic()
class Store:
"""The local scrobble database."""
def __init__(self, path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self.connection = sqlite3.connect(self.path)
self.connection.row_factory = sqlite3.Row
# WAL so a long backfill does not lock a reader out, and a relaxed sync
# because the whole store can be rebuilt from Last.fm if a crash eats
# the tail of it.
self.connection.execute("PRAGMA journal_mode = WAL")
self.connection.execute("PRAGMA synchronous = NORMAL")
self.connection.executescript(SCHEMA)
self._check_version()
def _check_version(self):
held = self.get_state("schema_version")
if held is None:
self.set_state("schema_version", SCHEMA_VERSION)
elif held != SCHEMA_VERSION:
raise RuntimeError(
f"{self.path} is schema version {held}, this build expects {SCHEMA_VERSION}; "
"delete it and let it rebuild"
)
def close(self):
self.connection.close()
def get_state(self, key):
row = self.connection.execute("SELECT value FROM state WHERE key = ?", (key,)).fetchone()
return row["value"] if row else None
def set_state(self, key, value):
with self.connection:
self.connection.execute(
"INSERT INTO state (key, value) VALUES (?, ?)"
" ON CONFLICT (key) DO UPDATE SET value = excluded.value",
(key, value),
)
def add_scrobbles(self, scrobbles):
"""Insert scrobbles, ignoring any already held. Returns how many were new."""
if not scrobbles:
return 0
with self.connection:
before = self.connection.total_changes
self.connection.executemany(
"INSERT OR IGNORE INTO scrobble"
" (uts, artist, track, album, artist_mbid, album_mbid, track_mbid)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
[
(
item.uts,
item.artist,
item.track,
item.album,
item.artist_mbid,
item.album_mbid,
item.track_mbid,
)
for item in scrobbles
],
)
return self.connection.total_changes - before
def replace_loved(self, rows):
"""Replace the loved-track list wholesale, in one transaction."""
with self.connection:
self.connection.execute("DELETE FROM loved")
self.connection.executemany(
"INSERT OR IGNORE INTO loved (artist, track, track_mbid, loved_at)"
" VALUES (?, ?, ?, ?)",
rows,
)
def scalar(self, sql):
return self.connection.execute(sql).fetchone()[0]
def newest_uts(self):
return self.scalar("SELECT MAX(uts) FROM scrobble")
def oldest_uts(self):
return self.scalar("SELECT MIN(uts) FROM scrobble")
def count(self):
return self.scalar("SELECT COUNT(*) FROM scrobble")
def fetch_page(client, user, to=None, since=None, page=1):
"""Return the scrobbles on one page of the recent-tracks history."""
payload = client.call(
"user.getRecentTracks",
{"user": user, "limit": PAGE_SIZE, "page": page, "from": since, "to": to},
)
block = payload.get("recenttracks") or {}
entries = as_list(block.get("track"))
parsed = [scrobble for scrobble in map(parse_scrobble, entries) if scrobble is not None]
attributes = block.get("@attr") or {}
return parsed, int(attributes.get("totalPages") or 1)
def backfill(client, store, user, now, limit=0):
"""Walk the history backwards until it runs out. Returns scrobbles added.
Each request asks for the newest page of everything at or before a cursor,
and the cursor is then moved to the oldest scrobble that came back. There is
no page number to keep across runs, so an interrupted backfill resumes from
whatever the database holds.
"""
if store.get_state("backfill_complete") == "yes":
return 0
cursor = store.oldest_uts()
if cursor is None:
cursor = now
page = 1
added = 0
requests = 0
while limit <= 0 or requests < limit:
scrobbles, _ = fetch_page(client, user, to=cursor, page=page)
requests += 1
if not scrobbles:
store.set_state("backfill_complete", "yes")
logger.info("backfill complete: %d scrobbles held", store.count())
return added
added += store.add_scrobbles(scrobbles)
page_oldest = min(scrobble.uts for scrobble in scrobbles)
if page_oldest < cursor:
cursor, page = page_oldest, 1
else:
# Every scrobble on this page shares the cursor's second. Moving the
# window would step over the rest of them, so take the next page of
# the same window instead.
page += 1
logger.info("backfill: %d added, reached %s", added, format_time(page_oldest))
logger.info("backfill paused after %d requests; resumes next pass", requests)
return added
def catch_up(client, store, user, now):
"""Fetch everything scrobbled since the newest scrobble held.
Bounded at both ends, so the window cannot shift under the paging while new
scrobbles arrive mid-run; anything that lands during the pass is picked up
by the next one.
"""
newest = store.newest_uts()
if newest is None:
return 0
added = 0
page = 1
while True:
scrobbles, total_pages = fetch_page(client, user, to=now, since=newest, page=page)
added += store.add_scrobbles(scrobbles)
if page >= total_pages:
return added
page += 1
def sync_loved(client, store, user):
"""Rebuild the loved-track table. Returns how many are loved."""
rows = []
page = 1
while True:
payload = client.call(
"user.getLovedTracks", {"user": user, "limit": PAGE_SIZE, "page": page}
)
block = payload.get("lovedtracks") or {}
for entry in as_list(block.get("track")):
artist = name_of(entry.get("artist"))
track = (entry.get("name") or "").strip()
if not artist or not track:
continue
loved_at = (entry.get("date") or {}).get("uts")
rows.append((artist, track, mbid_of(entry.get("mbid")), int(loved_at or 0) or None))
total_pages = int((block.get("@attr") or {}).get("totalPages") or 1)
if page >= total_pages:
break
page += 1
# Written only once the whole list has been fetched: a failure halfway
# through must not leave a truncated set of protected tracks behind.
store.replace_loved(rows)
return len(rows)
def format_time(uts):
"""Return a UTC timestamp as a readable date."""
if uts is None:
return "never"
return datetime.fromtimestamp(uts, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
def report(store, now):
"""Log what the store holds.
The MBID coverage line is the one to watch: scrobbles carrying a MusicBrainz
recording id can be joined to the library exactly, and everything else has
to go through name matching. That percentage predicts how well the next
stage will work, and whether it can be trusted to decide what is unplayed.
"""
total = store.count()
logger.info("scrobbles: %d", total)
if not total:
return
with_mbid = store.scalar("SELECT COUNT(*) FROM scrobble WHERE track_mbid IS NOT NULL")
logger.info(
"range: %s to %s", format_time(store.oldest_uts()), format_time(store.newest_uts())
)
logger.info("distinct artists: %d", store.scalar("SELECT COUNT(DISTINCT artist) FROM scrobble"))
logger.info(
"distinct tracks: %d",
store.scalar("SELECT COUNT(DISTINCT artist || ' - ' || track) FROM scrobble"),
)
logger.info("recording MBID present: %d (%.1f%%)", with_mbid, 100 * with_mbid / total)
logger.info("loved tracks: %d", store.scalar("SELECT COUNT(*) FROM loved"))
logger.info("backfill complete: %s", store.get_state("backfill_complete") == "yes")
top = store.connection.execute(
"SELECT artist, COUNT(*) AS plays FROM scrobble"
" GROUP BY artist ORDER BY plays DESC, artist LIMIT 10"
).fetchall()
for position, row in enumerate(top, start=1):
logger.info(" top artist %2d: %-40s %d", position, row["artist"][:40], row["plays"])
recent = store.connection.execute(
"SELECT artist, track, COUNT(*) AS plays FROM scrobble WHERE uts >= ?"
" GROUP BY artist, track ORDER BY plays DESC, artist LIMIT 10",
(now - 90 * 86400,),
).fetchall()
for position, row in enumerate(recent, start=1):
label = f"{row['artist']} - {row['track']}"
logger.info(" top track 90d %2d: %-50s %d", position, label[:50], row["plays"])
def run_once(client, store, user, now, backfill_limit):
"""Run a single ingest pass. Returns the number of scrobbles added."""
started = time.monotonic()
added = catch_up(client, store, user, now)
added += backfill(client, store, user, now, limit=backfill_limit)
loved = sync_loved(client, store, user)
logger.info(
"pass complete in %.1fs: %d scrobbles added, %d loved",
time.monotonic() - started,
added,
loved,
)
return added
def acquire_lock(database):
"""Take an exclusive lock so two passes cannot ingest into one store."""
database.parent.mkdir(parents=True, exist_ok=True)
handle = open(database.with_suffix(".lock"), "w") # noqa: SIM115 - held for the process
try:
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
handle.close()
return None
return handle
def build_parser():
"""Return the argument parser. Every option also reads an environment
variable, so the container can be configured without a command line."""
parser = argparse.ArgumentParser(
prog="music-curator",
description="Ingest a Last.fm scrobble history into a local store.",
)
parser.add_argument(
"--user",
default=os.getenv("MUSIC_CURATOR_LASTFM_USER"),
help="Last.fm username to read (env MUSIC_CURATOR_LASTFM_USER)",
)
parser.add_argument(
"--api-key",
default=os.getenv("MUSIC_CURATOR_LASTFM_API_KEY"),
help="Last.fm API key (env MUSIC_CURATOR_LASTFM_API_KEY)",
)
parser.add_argument(
"--db",
default=os.getenv("MUSIC_CURATOR_DB", "/data/curator.db"),
help="path to the SQLite store (env MUSIC_CURATOR_DB)",
)
parser.add_argument(
"--interval",
default=os.getenv("MUSIC_CURATOR_INTERVAL"),
help="repeat forever, waiting this long between passes, e.g. 6h (env MUSIC_CURATOR_INTERVAL)",
)
parser.add_argument(
"--request-delay",
type=float,
default=float(os.getenv("MUSIC_CURATOR_REQUEST_DELAY", REQUEST_DELAY_SECONDS)),
help="seconds between API requests (env MUSIC_CURATOR_REQUEST_DELAY)",
)
parser.add_argument(
"--backfill-limit",
type=int,
default=int(os.getenv("MUSIC_CURATOR_BACKFILL_LIMIT", "0")),
help="cap the backfill at this many requests per pass; 0 for no cap"
" (env MUSIC_CURATOR_BACKFILL_LIMIT)",
)
parser.add_argument(
"--report-only",
action="store_true",
help="report on the existing store without fetching anything",
)
return parser
def main(argv=None, clock=time.time):
"""Entry point. Returns a process exit code."""
logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=logging.INFO)
args = build_parser().parse_args(argv)
if not args.report_only and (not args.user or not args.api_key):
logger.error("both a Last.fm user and an API key are required")
return 2
try:
interval = parse_interval(args.interval) if args.interval else None
except ValueError as error:
logger.error("%s", error)
return 2
database = Path(args.db).resolve()
lock = acquire_lock(database)
if lock is None:
logger.error("another pass is already using %s", database)
return 3
store = Store(database)
if args.report_only:
report(store, int(clock()))
store.close()
lock.close()
return 0
client = Lastfm(args.api_key, delay=args.request_delay)
stopping = False
def stop(signum, _frame):
nonlocal stopping
stopping = True
logger.info("signal %d received; finishing the current pass", signum)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
try:
while True:
now = int(clock())
try:
run_once(client, store, args.user, now, args.backfill_limit)
except LastfmError as error:
logger.error("%s", error)
if interval is None:
return 1
report(store, now)
if interval is None or stopping:
return 0
logger.info("sleeping %ds", interval)
for _ in range(interval):
if stopping:
return 0
time.sleep(1)
finally:
store.close()
lock.close()
def run():
"""Console-script entry point."""
sys.exit(main())
if __name__ == "__main__":
run()