5 Commits
Author SHA1 Message Date
lyrathorpe 08f099aaa9 chore(release): v0.3.1 2026-08-24 16:42:06 +00:00
lyrathorpe 719a6372ad Merge pull request 'fix: distinguish a title collision from an attribution miss, and index for it' (#8) from fix/title-collisions-and-index into main
Build and publish container / build (push) Successful in 4m54s
Reviewed-on: #8
2026-08-24 17:37:12 +01:00
Emma Thorpe 61ce751ac5 fix: distinguish a title collision from an attribution miss, and index for it
Build and publish container / build (pull_request) Successful in 5m16s
Two faults in the split added last change, both visible in the first real run.

It reported 700 pairs as attribution disagreements on the strength of the
library holding the same title under a different artist. The examples show what
that actually caught: "Everyday" matched Def Leppard, "Kaleidoscope" matched
Chappell Roan, "Fight for Your Right" matched Motley Crue. Different songs that
happen to share a name. Across fifty thousand tracks that is not an edge case,
it is the common case, and presenting it as a matcher failure argues for exactly
the title-only matching tier that would produce this rubbish on purpose.

The signal for a real attribution miss is narrower: the library's own title
credits the artist the play is filed under, as in "Voodoo People (Pendulum
Remix)" against a scrobble credited to Pendulum. The normalised title has that
suffix stripped -- which is what let the two meet in the first place -- so the
raw title is searched for the name. Collisions are now counted and named
separately, as what they are.

The same query also took forty-seven seconds. lidarr_track was indexed on
(norm_artist, norm_title), which a lookup by title alone cannot use because its
leading column is the artist, so every unmatched key scanned all eighty-four
thousand tracks. Add the index on the title by itself; the query plan changes
from an automatic partial index to a covering one.
2026-08-24 17:35:32 +01:00
lyrathorpe d1c10d32d9 Merge pull request 'ci: build the image once instead of twice' (#7) from ci/one-build-not-two into main
Reviewed-on: #7
2026-08-24 17:31:47 +01:00
Emma Thorpe 45d99ff039 ci: build the image once instead of twice
Build and publish container / build (pull_request) Successful in 5m40s
A pull request took roughly eleven minutes to go green, and the log shows one
CACHED line in the whole run. The image was being built twice, in full.

The test stage is built by the runner's docker daemon. The runtime stage was
then built by docker/build-push-action, which runs under a buildx builder that
setup-buildx-action creates in its own container with its own cache. The two
share nothing, so the second build pulled the base image again, ran pip install
again, and exported the layers again -- about four and a half minutes, plus
another thirty-five seconds to boot buildkit. The comment above the test step
claimed those layers were shared, which is what made this look reasonable.

buildx earns that overhead when producing several architectures. This produces
linux/amd64 only, by an explicit decision recorded in the workflow, so it earns
nothing here. Use plain docker build against the same daemon that ran the
tests, and push with docker push. The runtime stage is a strict prefix of the
test stage, so every layer is a cache hit: measured at 1.3 seconds locally
against roughly four and a half minutes in CI.

The remaining time is the runner itself, which is slow in absolute terms --
pytest takes four seconds locally and a hundred and two in CI. That is not
something the workflow can fix.
2026-08-24 17:24:56 +01:00
5 changed files with 152 additions and 50 deletions
+29 -19
View File
@@ -45,7 +45,8 @@ jobs:
# The suite runs inside the image, against the interpreter that ships, # The suite runs inside the image, against the interpreter that ships,
# rather than against whatever the runner happens to provide. A failing # rather than against whatever the runner happens to provide. A failing
# test fails the build. Layers are shared with the push build below. # test fails the build. The runtime stage below is built from the same
# daemon afterwards, so its layers are already in cache.
- name: Run the test suite inside the image - name: Run the test suite inside the image
run: docker build --target test -t music-curator:test . run: docker build --target test -t music-curator:test .
@@ -124,9 +125,6 @@ jobs:
echo "release=${release}" >> "$GITHUB_OUTPUT" echo "release=${release}" >> "$GITHUB_OUTPUT"
echo "Computed bump=${bump}, release=${release}, base=${base}" echo "Computed bump=${bump}, release=${release}, base=${base}"
- name: Set up Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4
- name: Log in to the Gitea container registry - name: Log in to the Gitea container registry
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4
@@ -135,21 +133,33 @@ jobs:
username: ${{ github.repository_owner }} username: ${{ github.repository_owner }}
password: ${{ secrets.PACKAGES_TOKEN }} password: ${{ secrets.PACKAGES_TOKEN }}
- name: Build and push # Plain `docker build` rather than buildx. buildx boots its own buildkit
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 # in a container with a cache of its own, so it shared nothing with the
with: # test build above and rebuilt the image from the base image up -- two
context: . # full builds per run. It earns that cost when building for several
# Without this the last stage in the Dockerfile -- the test stage -- # platforms; this only ever targets the amd64 NAS, so it does not.
# would be what gets published. #
target: runtime # `--target runtime` is a strict prefix of the test stage, so every layer
# The NAS is the only host this runs on. Building arm64 as well would # is already in the daemon's cache and this resolves in seconds.
# mean emulating it under QEMU for no consumer. - name: Build the runtime image
platforms: linux/amd64 run: |
push: ${{ github.event_name != 'pull_request' }} set -euo pipefail
tags: ${{ steps.version.outputs.tags }} tags=()
labels: | while IFS= read -r tag; do
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} [ -n "$tag" ] && tags+=(-t "$tag")
org.opencontainers.image.revision=${{ github.sha }} done <<< "${{ steps.version.outputs.tags }}"
docker build --target runtime \
--label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
"${tags[@]}" .
- name: Push
if: github.event_name != 'pull_request'
run: |
set -euo pipefail
while IFS= read -r tag; do
[ -n "$tag" ] && docker push "$tag"
done <<< "${{ steps.version.outputs.tags }}"
# Record the release: write the computed version into pyproject.toml, then # Record the release: write the computed version into pyproject.toml, then
# commit and tag it, so the packaging metadata always matches the release # commit and tag it, so the packaging metadata always matches the release
+13 -9
View File
@@ -110,17 +110,21 @@ unmatched by an artist the library holds: N pairs, M plays
``` ```
That is a track that was played by an artist the library holds. It is then That is a track that was played by an artist the library holds. It is then
split again, because owning an artist is a weak proxy for owning a track: split three ways, because owning an artist is a weak proxy for owning a track
and a shared title is a weak proxy for a shared song:
- **the title exists under another artist** — an attribution disagreement, a - **the library's own title credits the scrobbled artist** — `Voodoo People
remixer or a guest billed as the artist. These are the genuine misses, and (Pendulum Remix)` against a play credited to Pendulum. Same song, filed
each is a candidate for being wrongly called cold in stage four. The report under the original artist. These are the genuine misses.
names the artist the library files them under. - **the same title under an unrelated artist** — a collision, not a miss.
- **the title is nowhere in the library** — never bought. No amount of matching Across fifty thousand tracks these are constant: `Everyday` is Rusko and
conjures a file that does not exist. also Def Leppard, `Kaleidoscope` is Delta Heavy and also Chappell Roan.
Matching on title alone would be far worse than missing them, which is why
there is no such tier.
- **the title is nowhere in the library** — never bought.
Counting both as matcher failures overstates the problem and would over-block Only the first is worth chasing. Counting all three as matcher failures
the cull. The report lists the worst of each by play count. overstates the problem and would over-block the cull.
## How the ingest works ## How the ingest works
+41 -15
View File
@@ -141,6 +141,11 @@ CREATE TABLE IF NOT EXISTS lidarr_track (
); );
CREATE INDEX IF NOT EXISTS lidarr_track_recording ON lidarr_track (recording_mbid); CREATE INDEX IF NOT EXISTS lidarr_track_recording ON lidarr_track (recording_mbid);
CREATE INDEX IF NOT EXISTS lidarr_track_norm ON lidarr_track (norm_artist, norm_title); CREATE INDEX IF NOT EXISTS lidarr_track_norm ON lidarr_track (norm_artist, norm_title);
-- Separate from the composite above, which a lookup by title alone cannot use:
-- its leading column is the artist. The report searches by title on its own, and
-- without this it scans every track for every unmatched key -- forty-seven
-- seconds on a library of eighty-four thousand.
CREATE INDEX IF NOT EXISTS lidarr_track_title ON lidarr_track (norm_title);
CREATE INDEX IF NOT EXISTS lidarr_track_album ON lidarr_track (album_id); CREATE INDEX IF NOT EXISTS lidarr_track_album ON lidarr_track (album_id);
-- One row per distinct thing listened to, with the verdict on whether it could -- One row per distinct thing listened to, with the verdict on whether it could
@@ -1125,47 +1130,68 @@ def coverage_report(store):
) )
# Owning an artist is a weak proxy for owning a track, so that figure alone # Owning an artist is a weak proxy for owning a track, so that figure alone
# overstates the matcher's failings. Split it. A title the library holds # overstates the matcher's failings. Split it -- but not on the title alone.
# under some other artist is an attribution disagreement -- a remixer # Across fifty thousand tracks, titles collide constantly: "Everyday" is
# credited as the artist, a guest billed as one -- and is a real miss. A # Rusko and also Def Leppard, "Kaleidoscope" is Delta Heavy and also
# title the library does not hold at all was simply never bought, and no # Chappell Roan. Matching those would be worse than missing them.
# amount of matching will conjure it. #
# The signal for a genuine attribution miss is that the library's own title
# credits the artist the scrobble is filed under -- "Voodoo People (Pendulum
# Remix)" against a play credited to Pendulum. The normalised title has that
# suffix stripped, which is exactly what let them meet, so the raw one has to
# be searched for the name.
attribution = store.connection.execute( attribution = store.connection.execute(
"SELECT COUNT(*) AS pairs, COALESCE(SUM(plays), 0) AS plays FROM scrobble_key k"
" WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
" AND EXISTS (SELECT 1 FROM lidarr_track t"
" WHERE t.norm_title = k.norm_track"
" AND instr(lower(t.title), lower(k.artist)) > 0)"
).fetchone()
collision = store.connection.execute(
"SELECT COUNT(*) AS pairs, COALESCE(SUM(plays), 0) AS plays FROM scrobble_key k" "SELECT COUNT(*) AS pairs, COALESCE(SUM(plays), 0) AS plays FROM scrobble_key k"
" WHERE k.track_id IS NULL" " WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)" " AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
" AND EXISTS (SELECT 1 FROM lidarr_track t WHERE t.norm_title = k.norm_track)" " AND EXISTS (SELECT 1 FROM lidarr_track t WHERE t.norm_title = k.norm_track)"
).fetchone() ).fetchone()
logger.info( logger.info(
" of those, the title exists under another artist: %d pairs, %d plays" " the library's title credits the scrobbled artist: %d pairs, %d plays"
" -- attribution disagreements, and the genuine misses", " -- remixes and guest spots, and the genuine misses",
attribution["pairs"], attribution["pairs"],
attribution["plays"], attribution["plays"],
) )
logger.info(
" same title under an unrelated artist: %d pairs, %d plays"
" -- title collisions, not misses; matching these would be a mistake",
collision["pairs"] - attribution["pairs"],
collision["plays"] - attribution["plays"],
)
logger.info( logger.info(
" the rest, %d pairs, %d plays: you own the artist but not the track", " the rest, %d pairs, %d plays: you own the artist but not the track",
suspect["pairs"] - attribution["pairs"], suspect["pairs"] - collision["pairs"],
suspect["plays"] - attribution["plays"], suspect["plays"] - collision["plays"],
) )
mismatched = store.connection.execute( mismatched = store.connection.execute(
"SELECT k.artist, k.track, k.plays," "SELECT k.artist, k.track, k.plays,"
" (SELECT a.name FROM lidarr_track t" " (SELECT t.title FROM lidarr_track t"
" JOIN lidarr_artist a ON a.id = t.artist_id" " WHERE t.norm_title = k.norm_track"
" WHERE t.norm_title = k.norm_track LIMIT 1) AS filed_under" " AND instr(lower(t.title), lower(k.artist)) > 0 LIMIT 1) AS library_title"
" FROM scrobble_key k" " FROM scrobble_key k"
" WHERE k.track_id IS NULL" " WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)" " AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
" AND EXISTS (SELECT 1 FROM lidarr_track t WHERE t.norm_title = k.norm_track)" " AND EXISTS (SELECT 1 FROM lidarr_track t"
" WHERE t.norm_title = k.norm_track"
" AND instr(lower(t.title), lower(k.artist)) > 0)"
" ORDER BY k.plays DESC, k.artist LIMIT 10" " ORDER BY k.plays DESC, k.artist LIMIT 10"
).fetchall() ).fetchall()
for position, row in enumerate(mismatched, start=1): for position, row in enumerate(mismatched, start=1):
logger.info( logger.info(
" attribution %2d: %-45s %4d plays, filed under %s", " attribution %2d: %-45s %4d plays, library has %r",
position, position,
f"{row['artist']} - {row['track']}"[:45], f"{row['artist']} - {row['track']}"[:45],
row["plays"], row["plays"],
row["filed_under"], row["library_title"],
) )
with_files = store.scalar("SELECT COUNT(*) FROM lidarr_track WHERE has_file = 1") with_files = store.scalar("SELECT COUNT(*) FROM lidarr_track WHERE has_file = 1")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "music-curator" name = "music-curator"
version = "0.3.0" version = "0.3.1"
description = "Ingest a Last.fm listening history and curate a music library from it" description = "Ingest a Last.fm listening history and curate a music library from it"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+68 -6
View File
@@ -55,8 +55,22 @@ LIBRARY = [
{ {
"name": "The Prodigy", "name": "The Prodigy",
"albums": [ "albums": [
{"title": "The Fat of the Land", "tracks": [{"title": "Breathe (Remastered)"}]} {
"title": "The Fat of the Land",
"tracks": [
{"title": "Breathe (Remastered)"},
# Credits its remixer in the title, which is the only signal
# separating a real attribution miss from a title collision.
{"title": "Voodoo People (Pendulum Remix)"},
], ],
}
],
},
# Held by the library in its own right, which is what puts its scrobbles
# inside the "artist the library holds" filter at all.
{
"name": "Pendulum",
"albums": [{"title": "Immersion", "tracks": [{"title": "Watercolour"}]}],
}, },
] ]
@@ -393,7 +407,7 @@ def test_keys_carry_the_play_count_and_the_span(tmp_path):
def test_re_indexing_drops_what_lidarr_no_longer_has(tmp_path): def test_re_indexing_drops_what_lidarr_no_longer_has(tmp_path):
"""The index is Lidarr's mirror, not an accumulation of everything ever seen.""" """The index is Lidarr's mirror, not an accumulation of everything ever seen."""
store = indexed(tmp_path, [scrobble_of("AC/DC", "Hells Bells")]) store = indexed(tmp_path, [scrobble_of("AC/DC", "Hells Bells")])
assert store.scalar("SELECT COUNT(*) FROM lidarr_artist") == 3 assert store.scalar("SELECT COUNT(*) FROM lidarr_artist") == 4
music_curator.index_library( music_curator.index_library(
music_curator.Lidarr("http://lidarr", "key", transport=FakeLidarr(LIBRARY[:1])), store music_curator.Lidarr("http://lidarr", "key", transport=FakeLidarr(LIBRARY[:1])), store
@@ -512,7 +526,7 @@ def test_albums_come_from_the_unfiltered_endpoint(tmp_path):
album_calls = [query for path, query in api.calls if path == "album"] album_calls = [query for path, query in api.calls if path == "album"]
assert album_calls == [{}] assert album_calls == [{}]
assert store.scalar("SELECT COUNT(*) FROM lidarr_album") == 3 assert store.scalar("SELECT COUNT(*) FROM lidarr_album") == 4
def test_a_bad_album_falls_back_to_asking_per_artist(tmp_path): def test_a_bad_album_falls_back_to_asking_per_artist(tmp_path):
@@ -526,7 +540,7 @@ def test_a_bad_album_falls_back_to_asking_per_artist(tmp_path):
assert store.scalar("SELECT COUNT(*) FROM lidarr_album WHERE artist_id = 2") == 0 assert store.scalar("SELECT COUNT(*) FROM lidarr_album WHERE artist_id = 2") == 0
# The other two artists keep their albums. # The other two artists keep their albums.
assert store.scalar("SELECT COUNT(*) FROM lidarr_album") == 2 assert store.scalar("SELECT COUNT(*) FROM lidarr_album") == 3
assert store.get_state("index_albums_skipped") == "1" assert store.get_state("index_albums_skipped") == "1"
@@ -549,10 +563,10 @@ def test_an_artist_lidarr_cannot_serve_does_not_kill_the_index(tmp_path):
music_curator.index_library(music_curator.Lidarr("http://lidarr", "key", transport=api), store) music_curator.index_library(music_curator.Lidarr("http://lidarr", "key", transport=api), store)
assert store.scalar("SELECT COUNT(*) FROM lidarr_artist") == 3 assert store.scalar("SELECT COUNT(*) FROM lidarr_artist") == 4
assert store.scalar("SELECT COUNT(*) FROM lidarr_track WHERE artist_id = 2") == 0 assert store.scalar("SELECT COUNT(*) FROM lidarr_track WHERE artist_id = 2") == 0
# The other two artists are indexed in full. # The other two artists are indexed in full.
assert store.scalar("SELECT COUNT(*) FROM lidarr_track") == 3 assert store.scalar("SELECT COUNT(*) FROM lidarr_track") == 5
assert store.get_state("index_skipped") == "1" assert store.get_state("index_skipped") == "1"
@@ -763,3 +777,51 @@ def test_the_report_survives_the_attribution_split(tmp_path):
store = indexed(tmp_path, [scrobble_of("Yellowcard", "Hells Bells")]) store = indexed(tmp_path, [scrobble_of("Yellowcard", "Hells Bells")])
music_curator.report(store, NOW) music_curator.report(store, NOW)
def attribution_pairs(store):
"""Unmatched pairs where the library's own title credits the scrobbled artist."""
return [
row["artist"]
for row in store.connection.execute(
"SELECT k.artist FROM scrobble_key k WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)"
" AND EXISTS (SELECT 1 FROM lidarr_track t"
" WHERE t.norm_title = k.norm_track"
" AND instr(lower(t.title), lower(k.artist)) > 0)"
)
]
def test_a_shared_title_is_not_an_attribution_miss(tmp_path):
"""Across fifty thousand tracks, titles collide constantly: "Everyday" is
Rusko and also Def Leppard. Matching those would be worse than missing."""
store = indexed(tmp_path, [scrobble_of("Yellowcard", "Hells Bells")])
# The library holds "Hells Bells", by AC/DC, and its title says nothing
# about Yellowcard. A collision, not a miss.
assert verdict(store, "Yellowcard", "Hells Bells") == ("none", None)
assert attribution_pairs(store) == []
def test_a_remix_credited_in_the_library_title_is_an_attribution_miss(tmp_path):
"""The library has "Voodoo People (Pendulum Remix)" under The Prodigy; the
scrobble credits Pendulum. Same song, different filing."""
store = indexed(tmp_path, [scrobble_of("Pendulum", "Voodoo People")])
assert attribution_pairs(store) == ["Pendulum"]
def test_the_title_index_is_used_for_the_report_lookup(tmp_path):
"""Without it the report scans every track for every unmatched key: forty-
seven seconds on a real library."""
store = indexed(tmp_path, [])
plan = "\n".join(
row[-1]
for row in store.connection.execute(
"EXPLAIN QUERY PLAN SELECT 1 FROM scrobble_key k WHERE k.track_id IS NULL"
" AND EXISTS (SELECT 1 FROM lidarr_track t WHERE t.norm_title = k.norm_track)"
)
)
assert "lidarr_track_title" in plan, plan