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:
@@ -2,6 +2,7 @@ import json
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import unicodedata
|
||||
from typing import Callable
|
||||
|
||||
import requests
|
||||
@@ -28,6 +29,39 @@ def _dirname(path: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _norm(s: str) -> str:
|
||||
"""Lowercase, strip diacritics, and reduce to alphanumeric tokens, so 'Beyoncé' matches a
|
||||
peer's 'Beyonce' folder and punctuation ('I Am… Sasha Fierce') doesn't defeat the match."""
|
||||
s = unicodedata.normalize("NFKD", s or "")
|
||||
s = "".join(ch for ch in s if not unicodedata.combining(ch))
|
||||
s = "".join(ch if ch.isalnum() else " " for ch in s.lower())
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def _path_matches(needle: str, path: str) -> bool:
|
||||
"""True if `needle` (an artist or album name) appears in a file path, tolerant of accents and
|
||||
punctuation. Every significant token (≥3 chars) must be present; for a very short needle
|
||||
(e.g. 'U2') the whole normalized string must appear."""
|
||||
npath = _norm(path)
|
||||
tokens = [t for t in _norm(needle).split() if len(t) >= 3]
|
||||
if not tokens:
|
||||
whole = _norm(needle)
|
||||
return bool(whole) and whole in npath
|
||||
return all(t in npath for t in tokens)
|
||||
|
||||
|
||||
def _keep_matching(responses: list, needle: str) -> list:
|
||||
"""Drop files — then now-empty responses — whose path doesn't match `needle`. Makes an album-
|
||||
or artist-only fallback search precise so we never grab a same-titled release by a different
|
||||
artist. Pure — unit-tested offline."""
|
||||
out = []
|
||||
for resp in responses:
|
||||
files = [f for f in (resp.get("files") or []) if _path_matches(needle, f.get("filename", ""))]
|
||||
if files:
|
||||
out.append({**resp, "files": files})
|
||||
return out
|
||||
|
||||
|
||||
def _parse_search_responses(responses: list) -> list[dict]:
|
||||
"""Turn slskd search responses into album candidates (one per peer+directory), ordered so the
|
||||
peers most likely to deliver quickly come first: a free upload slot, then higher upload speed,
|
||||
@@ -105,11 +139,12 @@ class SlskdClient:
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def search_album(self, artist: str, album: str) -> list[dict]:
|
||||
def _run_search(self, text: str) -> list:
|
||||
"""POST a search, wait for it to complete, and return the raw responses (a list)."""
|
||||
r = requests.post(
|
||||
f"{self._url}/api/v0/searches",
|
||||
headers=self._headers(),
|
||||
json={"searchText": f"{artist} {album}"},
|
||||
json={"searchText": text},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
@@ -122,7 +157,27 @@ class SlskdClient:
|
||||
break
|
||||
|
||||
responses = self._get(f"/api/v0/searches/{search_id}/responses")
|
||||
return _parse_search_responses(responses if isinstance(responses, list) else [])
|
||||
return responses if isinstance(responses, list) else []
|
||||
|
||||
def search_album(self, artist: str, album: str) -> list[dict]:
|
||||
responses = self._run_search(f"{artist} {album}")
|
||||
if not responses:
|
||||
# The Soulseek server silently drops searches containing certain blocklisted terms
|
||||
# (major-label artists / album titles like Adele, Rihanna, Beyoncé, "Lemonade") →
|
||||
# 0 responses. The blocked term poisons the whole query, but the OTHER field usually
|
||||
# isn't blocked, so retry with each field alone (normalized) and keep only results
|
||||
# whose path still matches the field we dropped. That recovers e.g. Beyoncé's
|
||||
# "I Am… Sasha Fierce" via the album title, without ever grabbing a same-titled album
|
||||
# by someone else.
|
||||
for query, needle in ((album, artist), (artist, album)):
|
||||
q = _norm(query)
|
||||
if not q:
|
||||
continue
|
||||
alt = _keep_matching(self._run_search(q), needle)
|
||||
if alt:
|
||||
responses = alt
|
||||
break
|
||||
return _parse_search_responses(responses)
|
||||
|
||||
def download(self, source_ref: str, dest: str, on_progress: Callable[[float], None]) -> dict:
|
||||
ref = json.loads(source_ref)
|
||||
|
||||
Reference in New Issue
Block a user