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:
@@ -1,3 +1,3 @@
|
||||
"""LinTunes — iTunes-style music library manager and player for Linux."""
|
||||
|
||||
__version__ = "0.20.0"
|
||||
__version__ = "0.20.1"
|
||||
|
||||
+99
-11
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user