From 2886c02a2ef35fc903c5858fb657db932e9f0c10 Mon Sep 17 00:00:00 2001 From: Emma Thorpe Date: Mon, 24 Aug 2026 17:15:56 +0100 Subject: [PATCH] feat: split unmatched listening by whether the library holds the title "Unmatched by an artist the library holds" was presented as the matcher's misses. On real data it is not: owning one album by an artist says nothing about owning a particular single of theirs, and most of that figure turned out to be drum and bass tracks streamed but never bought. Split it in two. A title the library holds under some other artist is an attribution disagreement -- a remixer credited as the artist, a guest billed as one -- and is a genuine miss worth fixing; the report now names the artist the library files it under, which is the information needed to judge it. A title the library does not hold at all under any artist was never bought, and no improvement to matching will conjure it. The distinction matters beyond presentation. That figure is the gate on the cull, and a gate computed from a number that overstates the failure rate blocks work that is actually safe to do. --- README.md | 16 +++++++++---- music_curator.py | 47 +++++++++++++++++++++++++++++++++++-- tests/test_music_curator.py | 31 ++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6fb41da..eebe9c4 100644 --- a/README.md +++ b/README.md @@ -109,10 +109,18 @@ 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. +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: + +- **the title exists under another artist** — an attribution disagreement, a + remixer or a guest billed as the artist. These are the genuine misses, and + each is a candidate for being wrongly called cold in stage four. The report + names the artist the library files them under. +- **the title is nowhere in the library** — never bought. No amount of matching + conjures a file that does not exist. + +Counting both as matcher failures overstates the problem and would over-block +the cull. The report lists the worst of each by play count. ## How the ingest works diff --git a/music_curator.py b/music_curator.py index 4a27a30..6186694 100644 --- a/music_curator.py +++ b/music_curator.py @@ -1119,12 +1119,55 @@ def coverage_report(store): " AND EXISTS (SELECT 1 FROM lidarr_artist a WHERE a.norm_name = k.norm_artist)" ).fetchone() logger.info( - "unmatched by an artist the library holds: %d pairs, %d plays -- these are the" - " matcher's misses, not music you do not own", + "unmatched by an artist the library holds: %d pairs, %d plays", suspect["pairs"], suspect["plays"], ) + # 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 + # under some other artist is an attribution disagreement -- a remixer + # credited as the artist, a guest billed as one -- and is a real miss. A + # title the library does not hold at all was simply never bought, and no + # amount of matching will conjure it. + 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)" + ).fetchone() + logger.info( + " of those, the title exists under another artist: %d pairs, %d plays" + " -- attribution disagreements, and the genuine misses", + attribution["pairs"], + attribution["plays"], + ) + logger.info( + " the rest, %d pairs, %d plays: you own the artist but not the track", + suspect["pairs"] - attribution["pairs"], + suspect["plays"] - attribution["plays"], + ) + + mismatched = store.connection.execute( + "SELECT k.artist, k.track, k.plays," + " (SELECT a.name FROM lidarr_track t" + " JOIN lidarr_artist a ON a.id = t.artist_id" + " WHERE t.norm_title = k.norm_track LIMIT 1) AS filed_under" + " 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)" + " ORDER BY k.plays DESC, k.artist LIMIT 10" + ).fetchall() + for position, row in enumerate(mismatched, start=1): + logger.info( + " attribution %2d: %-45s %4d plays, filed under %s", + position, + f"{row['artist']} - {row['track']}"[:45], + row["plays"], + row["filed_under"], + ) + with_files = store.scalar("SELECT COUNT(*) FROM lidarr_track WHERE has_file = 1") played = store.scalar( "SELECT COUNT(DISTINCT k.track_id) FROM scrobble_key k" diff --git a/tests/test_music_curator.py b/tests/test_music_curator.py index e376fa9..0a2e776 100644 --- a/tests/test_music_curator.py +++ b/tests/test_music_curator.py @@ -732,3 +732,34 @@ def test_a_bracketed_guest_credit_matches_the_bare_tag(tmp_path): method, track_id = verdict(store, "Yellowcard", "Here I Am Alive (feat. Someone)") assert method == "name" assert track_id is not None + + +def test_misses_are_split_by_whether_the_library_holds_the_title(tmp_path): + """Owning an artist is a weak proxy for owning a track. Counting both as + matcher failures overstates the problem and would over-block the cull.""" + store = indexed( + tmp_path, + [ + # The library holds "Hells Bells", but under AC/DC, not Yellowcard: + # an attribution disagreement, and a real miss. + scrobble_of("Yellowcard", "Hells Bells"), + # Yellowcard is in the library; this track is not, under any artist. + scrobble_of("Yellowcard", "A Single She Never Bought"), + ], + ) + + def count(extra): + return store.connection.execute( + "SELECT COUNT(*) 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)" + f" {extra}" + ).fetchone()[0] + + assert count("") == 2 + assert count("AND EXISTS (SELECT 1 FROM lidarr_track t WHERE t.norm_title = k.norm_track)") == 1 + + +def test_the_report_survives_the_attribution_split(tmp_path): + store = indexed(tmp_path, [scrobble_of("Yellowcard", "Hells Bells")]) + + music_curator.report(store, NOW)