feat: index the library from Lidarr and match it against the scrobbles
Build and publish container / build (pull_request) Successful in 10m2s

Stage two. The scrobble history says what was played by name; Lidarr says what
is owned, and where the files are. Neither is useful for curation until the two
are tied together, and the quality of that join is what decides whether the
later cull can be trusted at all.

The index is a wholesale rebuild of every artist, album and track Lidarr holds,
including file paths and the date each file landed -- the latter for the age
floor a cull will need. It is rebuilt rather than reconciled because Lidarr is
the authority and a deletion there has to disappear here, not linger as a
library entry with no file behind it. Every call is a GET; nothing is written
back.

Matching runs at the level of the distinct artist/track pair rather than the
individual play, because a verdict is a property of the name pair and there are
three plays for every one of them. Two tiers: a MusicBrainz recording id, which
Last.fm supplies per scrobble and Lidarr exposes as ForeignRecordingId, gives an
exact join; everything else falls to a normalised name comparison. There is
deliberately no third tier. A near-miss guess is worse than an admitted one,
since the entire purpose of the resulting number is to state how far the
matching can be relied on.

Normalisation folds the ways the two sides habitually disagree: case, accents,
guest credits that Last.fm puts in the artist field, trailing version suffixes,
ampersands, and a leading article. Punctuation needs two opposing rules and both
are load-bearing -- apostrophes are deleted so "Don't" meets "Dont", while every
other mark becomes a space so "AC/DC", "AC-DC" and "AC DC" meet as well. It errs
towards collapsing too much: a false match makes a track look played, a missed
match makes it look abandoned, and only the second one loses music.

The coverage report deliberately does not lead with matched versus unmatched.
Most unmatched listening is music that was never in the library and says nothing
about the matcher. The figure that matters is unmatched listening by an artist
the library does hold: a track that was played, sitting next to a file it should
have matched. The worst fifteen are listed by play count.

The schema gains its tables additively and migrates a version 1 store in place,
because rebuilding a nine-year history costs several thousand API requests.
This commit is contained in:
Emma Thorpe
2026-08-24 13:31:08 +01:00
parent 18f05d3d55
commit 5c4797ef38
5 changed files with 847 additions and 24 deletions
+53 -15
View File
@@ -7,22 +7,55 @@ which keeps an MP3 copy of a lossless library for an iPod. This one answers the
question that mirror cannot: which of it is worth carrying, and which of it has
not been played in years.
**This is stage one.** It ingests the scrobble history and nothing else. There
are no playlists yet, and nothing touches Lidarr or the music library. See
"Where this is going" below.
**This is stage two.** It ingests the scrobble history, indexes the library from
Lidarr, and matches one to the other. There are no playlists yet, and it writes
nothing back — every Lidarr call is a `GET`. See "Where this is going" below.
## What it does today
- Pulls the full Last.fm scrobble history into SQLite, then keeps it current.
- Tracks loved tracks separately, as the protected set for later stages.
- Reports what it holds, including the number that matters most: how many
scrobbles carry a MusicBrainz recording id.
- Indexes every artist, album and track Lidarr knows about, with file paths and
the date each file landed.
- Ties the two together and reports how well it managed.
That last figure decides the next stage. Lidarr exposes a `ForeignRecordingId`
on every track, which is the same identifier, so scrobbles carrying one can be
joined to the library exactly. The rest have to go through name matching, which
is where a curation tool goes wrong and starts recommending the deletion of
music you love. Measure the join rate before trusting the verdict.
## Matching
Two tiers, and no third.
| Tier | Key | Notes |
| ------ | --------------------------- | ----------------------------------------- |
| `mbid` | MusicBrainz recording id | Exact. Last.fm's per-scrobble `mbid` against Lidarr's `ForeignRecordingId` |
| `name` | Normalised artist and title | Everything the first tier could not carry |
| `none` | — | Recorded as a miss, never guessed at |
The normalisation is the load-bearing part, because the two sides disagree in
predictable ways. It folds case and accents, drops guest credits (`Yellowcard
feat. Tay Jardine` against a tag of `Yellowcard`), strips a trailing
version suffix (`(Remastered 2011)`, `- Live`), expands `&`, and removes a
leading `The`. Punctuation gets two different rules that pull against each
other and are both required: apostrophes are **deleted**, so `Don't` meets
`Dont`, while every other mark becomes a **space**, so `AC/DC`, `AC-DC` and
`AC DC` all meet as well.
It leans towards collapsing too much. A false match makes something look
played; a missed match makes something look abandoned. Only one of those
deletes music.
### Reading the coverage report
Matched against unmatched is the wrong comparison — most unmatched listening is
music that was never in the library, which says nothing at all about the
matcher. The line to watch is:
```
unmatched by an artist the library holds: N pairs, M plays
```
That is a track that was played, sitting beside a file it should have matched.
Those are the matcher's real misses, and every one is a candidate for being
wrongly called cold in stage four. The report lists the worst fifteen by play
count so they can be eyeballed.
## How the ingest works
@@ -76,6 +109,9 @@ music-curator --report-only # report on the store, fetch nothing
| `--interval` | `MUSIC_CURATOR_INTERVAL` | unset | Repeat forever, e.g. `45m`, `6h`, `1d` |
| `--request-delay` | `MUSIC_CURATOR_REQUEST_DELAY` | `0.25` | Seconds between API requests |
| `--backfill-limit` | `MUSIC_CURATOR_BACKFILL_LIMIT` | `0` | Cap backfill requests per pass; 0 for no cap |
| `--lidarr-url` | `MUSIC_CURATOR_LIDARR_URL` | unset | Lidarr base URL, e.g. `http://lidarr:8686` |
| `--lidarr-api-key` | `MUSIC_CURATOR_LIDARR_API_KEY` | unset | Lidarr API key |
| `--skip-index` | — | off | Match against the index already held |
| `--report-only` | — | off | Report without fetching |
A [Last.fm API key](https://www.last.fm/api/account/create) is all that is
@@ -107,9 +143,11 @@ docker build --target test . # what CI runs
pytest # needs pytest on PATH
```
The suite runs against a fake transport that reproduces the real service's
paging, its `from`/`to` semantics and its awkward response shapes. No network,
no credentials, no rate limit. On a Nix machine:
The suite runs against fake transports for both services. The Last.fm one
reproduces its paging, its `from`/`to` semantics and its awkward response
shapes; the Lidarr one serves a canned library split across the same four
endpoints the indexer calls, so the stitching is exercised rather than
stubbed. No network, no credentials, no rate limit. On a Nix machine:
```sh
nix shell nixpkgs#python3Packages.pytest -c pytest
@@ -120,8 +158,8 @@ nix shell nixpkgs#python3Packages.pytest -c pytest
| Stage | Status |
| ------------------------------------------------ | ------------ |
| Last.fm ingest and store | done |
| Lidarr index and the scrobble-to-track matcher | next |
| M3U playlists written into the mirror | after that |
| Lidarr index and the scrobble-to-track matcher | done |
| M3U playlists written into the mirror | next |
| Cold-music report, unmonitoring what is not played | last |
The cull will unmonitor cold albums in Lidarr and tag their artists. It will