feat(worker): recover blocklisted Soulseek searches via album/artist-only fallback
The Soulseek server silently drops searches containing certain blocklisted terms
(major-label artists / album titles — Adele, Rihanna, Beyoncé, "Lemonade", …),
returning 0 responses ("TimedOut"). The blocked term poisons the whole query even
though the OTHER field usually isn't blocked and the content is on the network
(e.g. searching "Beyoncé …" → 0, but "I Am… Sasha Fierce" alone → 250 with 136
real Beyoncé folders).
When the primary "{artist} {album}" search returns nothing, retry with each field
alone (normalized to fold accents/punctuation) and keep only results whose file
path still matches the dropped field (_keep_matching), so a same-titled album by a
different artist is never grabbed. Recovers blocklisted albums with a distinctive
title; safely yields nothing when the title is too generic (e.g. Adele "21") rather
than mis-grabbing. Fallback only fires on a 0-response primary, so the happy path is
unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
"""Soulseek search fallback: the Soulseek server drops searches containing blocklisted terms
|
||||
(major-label artists / album titles) → 0 responses. search_album retries with album-only then
|
||||
artist-only, keeping only path-matching results so it never grabs a same-titled foreign album."""
|
||||
import json
|
||||
|
||||
import lyra_worker.adapters._slskd as slskd_mod
|
||||
from lyra_worker.adapters._slskd import (
|
||||
SlskdClient,
|
||||
_keep_matching,
|
||||
_norm,
|
||||
_path_matches,
|
||||
)
|
||||
|
||||
|
||||
# --- pure helpers -----------------------------------------------------------------------------
|
||||
|
||||
def test_norm_strips_diacritics_and_punctuation():
|
||||
assert _norm("Beyoncé") == "beyonce"
|
||||
assert _norm("I Am… Sasha Fierce") == "i am sasha fierce"
|
||||
assert _norm("AC/DC") == "ac dc"
|
||||
|
||||
|
||||
def test_path_matches_is_accent_and_punctuation_tolerant():
|
||||
p = r"@@bckfj\All\Beyonce\I Am... Sasha Fierce CD1\03 Halo.flac"
|
||||
assert _path_matches("Beyoncé", p) # accent folded
|
||||
assert _path_matches("I Am… Sasha Fierce", p) # ellipsis / punctuation ignored
|
||||
assert not _path_matches("Rihanna", p)
|
||||
|
||||
|
||||
def test_path_matches_short_needle_requires_whole_string():
|
||||
assert _path_matches("U2", r"x\U2 - Achtung Baby\01.flac")
|
||||
assert not _path_matches("U2", r"x\Blur - 13\01.flac")
|
||||
|
||||
|
||||
def test_keep_matching_drops_foreign_and_empty_responses():
|
||||
responses = [
|
||||
{"username": "a", "files": [{"filename": r"x\Rihanna - Unapologetic\01.flac"}]},
|
||||
{"username": "b", "files": [{"filename": r"x\Someone Else - Unapologetic\01.flac"}]},
|
||||
]
|
||||
kept = _keep_matching(responses, "Rihanna")
|
||||
assert [r["username"] for r in kept] == ["a"]
|
||||
|
||||
|
||||
# --- fallback orchestration (HTTP faked) ------------------------------------------------------
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, payload):
|
||||
self._p = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return self._p
|
||||
|
||||
|
||||
class _FakeSearch:
|
||||
"""Routes slskd search HTTP by URL: POST returns an id, GET on the search returns a completed
|
||||
state, GET on /responses returns the canned responses for that query. Records queries sent."""
|
||||
def __init__(self, by_query):
|
||||
self.by_query = by_query # {searchText: [responses]}
|
||||
self.queries = [] # searchText values POSTed, in order
|
||||
self._id_query = {}
|
||||
self._n = 0
|
||||
|
||||
def post(self, url, **kw):
|
||||
text = kw["json"]["searchText"]
|
||||
self.queries.append(text)
|
||||
self._n += 1
|
||||
sid = f"s{self._n}"
|
||||
self._id_query[sid] = text
|
||||
return _Resp({"id": sid})
|
||||
|
||||
def get(self, url, **kw):
|
||||
if url.endswith("/responses"):
|
||||
sid = url.rsplit("/", 2)[-2]
|
||||
return _Resp(self.by_query.get(self._id_query[sid], []))
|
||||
return _Resp({"state": "Completed, TimedOut"})
|
||||
|
||||
|
||||
def _client():
|
||||
return SlskdClient({"slskd.url": "http://x", "slskd.api_key": "k", "slskd.downloadsDir": "/x"})
|
||||
|
||||
|
||||
def _install(monkeypatch, fake):
|
||||
monkeypatch.setattr(slskd_mod, "requests", fake)
|
||||
monkeypatch.setattr(slskd_mod.time, "sleep", lambda _s: None)
|
||||
|
||||
|
||||
def test_primary_hit_does_not_trigger_fallback(monkeypatch):
|
||||
fake = _FakeSearch({
|
||||
"Rihanna Unapologetic": [
|
||||
{"username": "a", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
|
||||
"files": [{"filename": r"x\Rihanna - Unapologetic\01.flac", "size": 5}]},
|
||||
],
|
||||
})
|
||||
_install(monkeypatch, fake)
|
||||
out = _client().search_album("Rihanna", "Unapologetic")
|
||||
assert len(out) == 1
|
||||
assert fake.queries == ["Rihanna Unapologetic"] # no fallback searches
|
||||
|
||||
|
||||
def test_album_only_fallback_recovers_blocklisted_artist(monkeypatch):
|
||||
# Primary (artist blocked) → 0; album-only returns the artist's folder → recovered.
|
||||
fake = _FakeSearch({
|
||||
"Beyoncé I Am… Sasha Fierce": [], # blocked
|
||||
"i am sasha fierce": [
|
||||
{"username": "a", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
|
||||
"files": [{"filename": r"x\Beyonce - I Am... Sasha Fierce\01.flac", "size": 5}]},
|
||||
{"username": "b", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
|
||||
"files": [{"filename": r"x\Other Artist - I Am Sasha Fierce\01.flac", "size": 5}]},
|
||||
],
|
||||
})
|
||||
_install(monkeypatch, fake)
|
||||
out = _client().search_album("Beyoncé", "I Am… Sasha Fierce")
|
||||
assert fake.queries == ["Beyoncé I Am… Sasha Fierce", "i am sasha fierce"]
|
||||
peers = [json.loads(c["source_ref"])["username"] for c in out]
|
||||
assert peers == ["a"] # foreign same-titled album dropped
|
||||
|
||||
|
||||
def test_artist_only_fallback_when_album_title_blocked(monkeypatch):
|
||||
# Album-only also blocked/empty → fall through to artist-only, filtered by album in path.
|
||||
fake = _FakeSearch({
|
||||
"Foo Lemonade": [],
|
||||
"lemonade": [], # album title itself blocked
|
||||
"foo": [
|
||||
{"username": "a", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
|
||||
"files": [{"filename": r"x\Foo - Lemonade\01.flac", "size": 5}]},
|
||||
],
|
||||
})
|
||||
_install(monkeypatch, fake)
|
||||
out = _client().search_album("Foo", "Lemonade")
|
||||
assert fake.queries == ["Foo Lemonade", "lemonade", "foo"]
|
||||
assert len(out) == 1
|
||||
|
||||
|
||||
def test_fallback_yields_nothing_when_title_too_generic(monkeypatch):
|
||||
# 'Adele 21': artist blocked, album title '21' returns lots of non-Adele content → filtered
|
||||
# to nothing rather than grabbing a wrong album.
|
||||
fake = _FakeSearch({
|
||||
"Adele 21": [],
|
||||
"21": [
|
||||
{"username": "z", "hasFreeUploadSlot": True, "uploadSpeed": 1, "queueLength": 0,
|
||||
"files": [{"filename": r"x\21 Savage - Savage Mode\01.flac", "size": 5}]},
|
||||
],
|
||||
"adele": [], # artist blocked
|
||||
})
|
||||
_install(monkeypatch, fake)
|
||||
out = _client().search_album("Adele", "21")
|
||||
assert out == []
|
||||
Reference in New Issue
Block a user