v0.20.1: one fingerprint, six artists

trav's "Dionne Farris - I Know" kept identifying as Jay-Z, against ID3 tags
that plainly said otherwise. AcoustID was right; the ranking threw the answer
away.

The fingerprint matched one AcoustID result at 0.97, and six recordings hang
off it: Dionne Farris twice, plus Jay-Z, Marisela, New Atlantic and David
Essex, all of whom recorded a song called "I Know". A result's score belongs
to the *audio*, so every linked recording carries it however wrong the link
is. With the scores tied, ranking fell through to the duration bucket, where
Jay-Z's 222.7 s beat Dionne's 227.3 s against a 224 s file. The tags never got
a vote: the hint sat below duration in the sort key.

So the lookup now asks who submitted each link. `sources` joins LOOKUP_META —
475 people linked that audio to Dionne Farris, 6 to Jay-Z, 1 each to the rest
— and _link_tier sinks anything under a tenth of the strongest link in the
same result. The share is relative, never an absolute count, and a missing
count ranks as real: an obscure song's true link may have two submissions
against a stray's one, and rounds 45-46's payloads rank unchanged.

And it asks what the file already says. artist_hint_for gathers the artist
tag, the album artist and the artist in the filename; _artist_agreement counts
the words shared with a candidate's credit, placeholders dropped. Like every
hint since round 46 it only chooses among what AcoustID returned.

New key order: stray tier, artist agreement, duration bucket, hint overlap,
release rank — who, which take, which release. Artist above duration is the
whole fix; duration still separates two takes by one artist.

Verified live against the reported file: the proposal is now I Know — Dionne
Farris — Wild Seed - Wild Flower (1994), track 1, with all five mis-tagged
artists off the dropdown. The real response is pinned in tests/test_round52.py.

Claude-Session: https://claude.ai/code/session_01SKXUgsBBwe3qaHEjeV8ubP
This commit is contained in:
2026-09-11 20:41:33 -05:00
parent 1280c89015
commit 4d069d2d63
5 changed files with 383 additions and 13 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.20.0"
__version__ = "0.20.1"
+99 -11
View File
@@ -25,7 +25,7 @@ from lintunes import filename_tags
ACOUSTID_LOOKUP_URL = "https://api.acoustid.org/v2/lookup"
LOOKUP_META = "recordings releasegroups releases tracks compress"
LOOKUP_META = "recordings releasegroups releases tracks sources compress"
SCORE_THRESHOLD = 0.5 # AcoustID scores below this are noise
MAX_CANDIDATES = 8
TIMEOUT_S = 15
@@ -155,6 +155,63 @@ def _hint_overlap(hint_tokens: set, candidate: IdentifyCandidate) -> int:
for token in hint_tokens & _tokens(text))
# A link backed by fewer than this share of the strongest link's submissions
# is a stray rather than a rival: 6 against 475 is somebody's mis-tag, while
# 3 against 20 is a genuinely contested song.
STRAY_LINK_SHARE = 0.1
def _link_tier(recording: dict, strongest: int) -> int:
"""0 for a real link, 1 for a stray, by how many people submitted it.
One AcoustID result is one piece of *audio*, and its score says how well
the file matched that audio — so every recording linked to it shares that
score, however wrong the link is. Links are user-submitted, and a song
whose title another artist also used collects mis-tags: trav's Dionne
Farris "I Know" is linked to Jay-Z's "I Know", Marisela's, David Essex's
and New Atlantic's. What separates them is how many people submitted each
link — 475 against 6, 1, 1 and 1 — which is the only field in the response
that knows the difference.
Relative to the strongest link, never an absolute count: an obscure song's
real link may have two submissions against a stray's one. Unknown counts
(an older payload, or a result where nobody reported any) rank as real, so
nothing is demoted on missing evidence.
"""
sources = recording.get("sources")
if not isinstance(sources, int) or strongest <= 0:
return 0
return 1 if sources < strongest * STRAY_LINK_SHARE else 0
def _strongest_link(result: dict) -> int:
"""The most submissions behind any one link of an AcoustID result."""
counts = [r.get("sources") for r in result.get("recordings", [])]
return max((c for c in counts if isinstance(c, int)), default=0)
# Words that name no artist, so agreeing with them means nothing.
_PLACEHOLDER_ARTIST = {"unknown", "various", "artist", "artists"}
def _artist_tokens(text: str) -> set:
"""Comparable words from an artist credit, placeholders dropped."""
return {t for t in _tokens(text) if not t.isdigit()} - _PLACEHOLDER_ARTIST
def _artist_agreement(artist_tokens: set, candidate: IdentifyCandidate) -> int:
"""How much a candidate's artist echoes the one the file already names.
Only ever chooses *among* the recordings AcoustID linked to this audio, so
like the rest of the hint it can never invent a candidate — it just stops a
file whose tags plainly say "dionne farris" from being told it is Jay-Z.
"""
if not artist_tokens:
return 0
text = " ".join(p for p in (candidate.artist, candidate.album_artist) if p)
return len(artist_tokens & _artist_tokens(text))
def _pick_release(releasegroup: dict) -> dict | None:
"""Earliest dated release that carries mediums (track positions); falls
back to the earliest dated one, then the first."""
@@ -192,7 +249,8 @@ def _track_position(release: dict | None) -> tuple[int | None, int | None,
def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
limit: int = MAX_CANDIDATES, hint: str = "",
duration: float | None = None) -> list[IdentifyCandidate]:
duration: float | None = None,
artist_hint: str = "") -> list[IdentifyCandidate]:
"""Turn an AcoustID lookup response into ranked candidates.
One candidate per release group of each matched recording, so the
@@ -203,20 +261,32 @@ def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
came out, not by which pressing this file happens to be from.
``hint`` is whatever the file already claims to be (its tags and its
filename). It never invents a candidate, but it breaks ties: a release
whose words echo the hint outranks one that doesn't, and a year named in
the hint wins when it predates anything the database knows — MusicBrainz
often holds only a reissue's date, with the original pressing undated.
filename), and ``artist_hint`` is just the artist part of that. Neither
invents a candidate; they decide between the ones AcoustID returned.
The order is *who*, then *which take*, then *which release*: a stray link
(see ``_link_tier``) sinks below the links people actually submitted, then
an artist agreeing with the file's own tags wins, then the recording whose
length matches, and only then the release whose words echo the hint. Artist
before duration is the Dionne Farris rule — five artists' mis-tags hang off
that one fingerprint, and Jay-Z's "I Know" happened to be 1.5 s closer.
A year named in the hint still wins when it predates anything the database
knows: MusicBrainz often holds only a reissue's date, with the original
pressing undated.
"""
hint_tokens = _tokens(hint)
hint_year = _single_year(hint)
artist_tokens = _artist_tokens(artist_hint)
scored = []
seen = set()
for result in payload.get("results", []):
score = result.get("score", 0)
if score < threshold:
continue
strongest = _strongest_link(result)
for recording in result.get("recordings", []):
tier = _link_tier(recording, strongest)
length = _duration_bucket(recording, duration)
title = recording.get("title")
artist = _join_artists(recording.get("artists"))
@@ -232,7 +302,9 @@ def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
if title:
bare = IdentifyCandidate(score=score, name=title,
artist=artist, year=hint_year)
scored.append(((-round(score, 2), length,
scored.append(((-round(score, 2), tier,
-_artist_agreement(artist_tokens, bare),
length,
-_hint_overlap(hint_tokens, bare),
4, 9999), bare))
continue
@@ -256,7 +328,9 @@ def parse_lookup(payload: dict, threshold: float = SCORE_THRESHOLD,
disc_number=disc,
)
rank, rg_year = _releasegroup_sort_key(releasegroup)
scored.append(((-round(score, 2), length,
scored.append(((-round(score, 2), tier,
-_artist_agreement(artist_tokens, candidate),
length,
-_hint_overlap(hint_tokens, candidate),
rank, rg_year), candidate))
scored.sort(key=lambda pair: pair[0])
@@ -278,6 +352,16 @@ def hint_for(track) -> str:
return " ".join(p for p in parts if p)
def artist_hint_for(track) -> str:
"""Who the file already says made this — its artist tags plus the artist
its own name carries. Ranks the recordings linked to one fingerprint."""
parts = [track.artist or "", getattr(track, "album_artist", "") or ""]
if track.location:
parts.append(filename_tags.parse_filename(track.location)
.get("artist", ""))
return " ".join(p for p in parts if p)
def candidate_from_filename(track) -> IdentifyCandidate | None:
"""A proposal read off the file's own name, for the very common case that
no database has ever heard of the song.
@@ -328,7 +412,8 @@ def fingerprint_file(location: str) -> tuple[int, str]:
def lookup_fingerprint(api_key: str, duration: int, fingerprint: str,
hint: str = "") -> list[IdentifyCandidate]:
hint: str = "",
artist_hint: str = "") -> list[IdentifyCandidate]:
import requests
response = requests.post(
ACOUSTID_LOOKUP_URL,
@@ -342,7 +427,8 @@ def lookup_fingerprint(api_key: str, duration: int, fingerprint: str,
if payload.get("status") != "ok":
message = (payload.get("error") or {}).get("message", "lookup failed")
raise RuntimeError(message)
return parse_lookup(payload, hint=hint, duration=duration)
return parse_lookup(payload, hint=hint, duration=duration,
artist_hint=artist_hint)
class TrackIdentifier(QObject):
@@ -360,6 +446,7 @@ class TrackIdentifier(QObject):
track_id = track.track_id
location = track.location
hint = hint_for(track)
artist_hint = artist_hint_for(track)
guess = candidate_from_filename(track)
def work():
@@ -372,7 +459,8 @@ class TrackIdentifier(QObject):
if not api_key:
raise RuntimeError("no AcoustID key set")
duration, fp = fingerprint_file(location)
candidates = lookup_fingerprint(api_key, duration, fp, hint)
candidates = lookup_fingerprint(api_key, duration, fp, hint,
artist_hint)
except Exception as e:
# A file too short or too odd to fingerprint still has a name.
if guess is not None: