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)