diff --git a/README.md b/README.md index 053e970..fe4d4cb 100644 --- a/README.md +++ b/README.md @@ -218,9 +218,24 @@ and finally the artist itself. Since most requests are answered by the first query, the extra searches cost nothing in the common case — `MUSE_MAX_QUERIES` caps how far it will go. -Dropping the artist from the *query* does not mean ignoring it. The artist is -then matched against the whole **folder path**, which is where a well-organised -share puts it anyway: +Dropping the artist from the *query* does not mean ignoring it — it makes the +artist matter more. A bare-title search stops returning "this song from various +peers" and starts returning *every song anyone ever called Chicago*: The Fall, +Groove Armada, Sufjan Stevens. The artist is then the only thing separating the +right recording from a different song entirely, so it is a **gate**: candidates +whose path names the artist are preferred outright, and ones that do not are +used only when nothing else is left. + +That has to be a gate rather than a weighting, and the reason is worth stating. +A perfect title with no artist scores 0.75, comfortably above `MUSE_MIN_MATCH`, +so as a mere contribution it loses to availability — and an idle peer holding +the wrong song beats a busy one holding the right song. That is not +hypothetical either: it filed The Fall's "Chicago, Now!" as Michael Jackson. +The same principle the confidence score is built on applies here: a fast peer +must not be able to promote a wrong match. + +The artist is matched against the whole **folder path**, which is where a +well-organised share puts it anyway: ``` anjya music/Michael Jackson/1982 - Thriller [flac]/06 - Billie Jean.flac diff --git a/app.py b/app.py index eab156a..c1a0507 100644 --- a/app.py +++ b/app.py @@ -481,6 +481,30 @@ def similarity(want: str, got: str) -> float: return max(seq, len(a & b) / len(a) if a else 0.0) +def artist_confirmed(remote_path: str, artist: str) -> bool: + """Is the requested artist actually named somewhere in this path? + + This is a gate rather than a score because of what the query ladder does. + Once a filtered artist name forces the search down to the bare title, the + result set stops being "this song from various peers" and becomes "every + song anyone ever called Chicago" -- The Fall, Groove Armada, Sufjan + Stevens. The artist is then the only thing distinguishing the right + recording from a completely different song, and a weighted contribution is + not enough: 0.75 for a perfect title with no artist still clears MIN_MATCH, + and an available stranger will out-score a queued match every time. + """ + na = norm(artist) + if not na: + return False + path = norm(remote_path.replace("\\", "/")) + if na in path: + return True + # Every distinctive word, in any order: catches "Jackson, Michael" and + # "MJ - Chicago (Michael Jackson)" without matching on "the" or "and". + words = [w for w in na.split() if len(w) > 3] + return bool(words) and all(w in path for w in words) + + def match_ratio(remote_path: str, artist: str, title: str) -> float: """How well a remote path matches the track asked for, 0..1. @@ -1078,6 +1102,20 @@ async def pick_and_enqueue(job_id: str, artist: str, title: str, set_state(job_id, "failed", no_candidate_detail(seen, stats, queries)) return + # Prefer candidates whose path actually names the artist, and only settle + # for ones that do not when there is nothing else. Availability is a + # tiebreak among plausible files, never a reason to take the wrong song. + named = {k: v for k, v in pool.items() + if artist_confirmed(v[2]["filename"], artist)} + if named: + if len(named) < len(pool): + log.info("job %s keeping %d/%d candidates that name %r", + job_id[:8], len(named), len(pool), artist) + pool = named + else: + log.info("job %s no candidate names %r; falling back to title-only " + "matches, which review will have to judge", job_id[:8], artist) + ranked = sorted(pool.values(), key=lambda r: -r[0]) cands = spread_by_peer(ranked)[:CANDIDATES] with db() as c: diff --git a/tests/test_acquisition.py b/tests/test_acquisition.py index feebae4..27587f1 100644 --- a/tests/test_acquisition.py +++ b/tests/test_acquisition.py @@ -161,6 +161,35 @@ def main() -> int: check("reordered filename still matches", app.match_ratio(r"x\Creep - Radiohead.mp3", "Radiohead", "Creep") == 1.0) + print("\nthe artist gate") + check("artist named in the folder is confirmed", + app.artist_confirmed(r"m\Michael Jackson\Xscape\02 Chicago.flac", + "Michael Jackson")) + check("a different band's song with the same title is not", + not app.artist_confirmed( + r"@@jwbvv\Music\The Fall\1990-Extricate\08-Chicago, Now!.flac", + "Michael Jackson")) + check("out-of-order naming still counts", + app.artist_confirmed(r"x\Jackson, Michael - Chicago.flac", + "Michael Jackson")) + + # The real regression: bare-title search returns every song called + # "Chicago", and an available stranger used to outrank a queued match. + gate = FakeSlskd(per_query={"Chicago": [ + peer("stranger", r"@@x\Music\The Fall\1990-Extricate\08-Chicago, Now!.flac", + slot=True, queue=0), + peer("correct", r"m\Michael Jackson\XSCAPE\02 - Chicago.flac", + slot=False, queue=300), + ]}) + app.slskd_client = gate + make_job("j6", "Michael Jackson", "Chicago") + asyncio.run(app.pick_and_enqueue("j6", "Michael Jackson", "Chicago")) + r = job_row("j6") + check("the right artist wins even from a peer with 300 queued", + r["username"] == "correct", f"picked {r['username']}") + check("and the wrong-artist file is dropped, not merely ranked lower", + len(json.loads(r["candidates"] or "[]")) == 0, r["candidates"]) + print("\ncandidate ladder") fake = FakeSlskd(per_query={ "Billie Jean": [peer("alice", r"m\Michael Jackson\Billie Jean.flac"),