feat: split unmatched listening by whether the library holds the title #6

Merged
lyrathorpe merged 1 commits from diag/split-misses-by-ownership into main 2026-08-24 17:19:09 +01:00
3 changed files with 88 additions and 6 deletions
+12 -4
View File
@@ -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
+45 -2
View File
@@ -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"
+31
View File
@@ -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)