Add query-shortening retry + qualifier guard to Tidal search

Tidal's search engine chokes on long queries with multiple qualifier
words (remix credits, edit labels, bonus-disc markers). User reported
case: "maduk transformations remixed fire away fred v remix" returns 0,
but shortening to "maduk transformations remixed fire away" works.

Behaviour change:
- On a 0-result search, retry with progressively-shortened variants
  (capped at 5 total attempts, 100ms pause between).
- Variants (in priority order):
    1. strip trailing "(...)" / "[...]"
    2. strip all parentheticals/brackets
    3-5. drop last 1 / 2 / 3 tokens
    6. keep first half of tokens (rounded up)
- Dedupes so identical variants don't re-query.

Safety — qualifier-aware filter:
- Variant keywords (Live / Remix / Acoustic / Extended / Unplugged /
  Instrumental / Karaoke / etc.) are extracted from the original query
  using word-boundary match so "edit" doesn't match "edition" and
  "mix" doesn't match "remixed".
- If the original query carries any qualifiers, fallback results MUST
  contain those qualifiers in their track names — otherwise a shortened
  query could silently downgrade "Song (Live)" to the studio "Song".
- Tracks that fail the filter are dropped. If no variant produces
  qualifier-matching tracks, returns ([], []) — the same outcome as the
  original code, so no regression.

Contract preservation:
- Never raises to caller (outer try/except catches orchestration errors).
- Returns ([], []) on any failure path, same as original.
- Original-query successes take the same code path as before — no
  behavioural change for queries that already work.
- Defensive guards for None/empty/non-string query (early return).

Logging:
- Preserves original warning/error/info messages for back-compat log
  scraping.
- Adds fallback-success INFO log ("Tidal fallback query succeeded: ...")
  so successful retries are visible in production logs.
- Adds qualifier-filter INFO/DEBUG logs with kept/total counts.
- Per-attempt exception logs at DEBUG (not ERROR) to avoid noise when
  retries succeed.
- Traceback preserved on final failure.

Tests (16 regression tests in tests/test_tidal_search_shortening.py):
- Skowl's reported query reaches his working variant within the cap.
- Paren/bracket stripping priority.
- Short queries produce no variants.
- All variants unique (dedup guard).
- Progressive token drops present for long queries.
- Qualifier extraction is word-bounded (no "edit" in "edition").
- Qualifier extraction is case-insensitive.
- Track name filter requires ALL qualifiers.
- Empty-qualifier list passes every track (original-query behaviour).

All 292 tests pass.
This commit is contained in:
Broque Thomas 2026-04-22 07:42:16 -07:00
parent 47ced912b9
commit 0d0bbf38c9
2 changed files with 358 additions and 6 deletions

View file

@ -244,6 +244,98 @@ class TidalDownloadClient:
logger.error(f"Tidal connection check failed: {e}")
return False
# Words that distinguish a specific audio variant from the original track.
# If any of these appear in a query, the fallback-retry results must also
# contain them — otherwise we'd silently downgrade a "(Live)" or
# "(Acoustic)" search to the studio version when shortened queries match
# too broadly.
_QUALIFIER_KEYWORDS = frozenset({
'remix', 'mix', 'edit', 'version', 'dub', 'rmx', 'vip', 'cut',
'rework', 'bootleg', 'flip',
'live', 'concert', 'unplugged', 'acoustic', 'session',
'instrumental', 'karaoke', 'demo', 'bonus',
'extended', 'radio',
})
@classmethod
def _extract_qualifiers(cls, query: str) -> List[str]:
"""Return the qualifier keywords that appear as whole words in the
query (case-insensitive). Word-boundary match prevents false hits like
"edit" matching "edition" or "mix" matching "remix"."""
if not query:
return []
found = []
q_lower = query.lower()
for kw in cls._QUALIFIER_KEYWORDS:
if re.search(r'\b' + re.escape(kw) + r'\b', q_lower):
found.append(kw)
return found
@staticmethod
def _track_name_contains_qualifiers(track_name: str, qualifiers: List[str]) -> bool:
"""True iff the track name contains every qualifier as a whole word."""
if not qualifiers:
return True
if not track_name:
return False
name_lower = track_name.lower()
for kw in qualifiers:
if not re.search(r'\b' + re.escape(kw) + r'\b', name_lower):
return False
return True
@staticmethod
def _generate_shortened_queries(original: str) -> List[str]:
"""Generate progressively-shorter variants of a search query.
Tidal's search engine chokes on long queries with lots of qualifiers
(remix credits, edit labels, bonus-disc markers). When the original
returns 0 results, we retry with shortened variants in order of
conservativeness the first variant that returns results wins.
Variants are returned in priority order. Dedupes against the original
and against previously-added variants so we never issue duplicate
requests.
"""
variants: List[str] = []
seen = {original.strip().lower()}
def _add(candidate: str) -> None:
candidate = candidate.strip()
if candidate and candidate.lower() not in seen:
variants.append(candidate)
seen.add(candidate.lower())
# 1. Strip a trailing parenthetical/bracketed suffix.
# "Song (Radio Edit)" → "Song"
_add(re.sub(r'\s*[\(\[][^\)\]]*[\)\]]\s*$', '', original))
# 2. Strip ALL parentheticals/brackets (mid-string too).
# "Song (feat X) [Remix]" → "Song"
_add(re.sub(r'\s*[\(\[][^\)\]]*[\)\]]', ' ', original))
tokens = original.split()
# 3. Drop the last token — covers trailing 1-word modifiers
# (e.g. "… Remix", "… Extended").
if len(tokens) >= 3:
_add(' '.join(tokens[:-1]))
# 4. Drop the last two tokens.
if len(tokens) >= 4:
_add(' '.join(tokens[:-2]))
# 5. Drop the last three tokens — covers "fred v remix" style
# 3-word modifiers common in remix/bonus track names.
if len(tokens) >= 5:
_add(' '.join(tokens[:-3]))
# 6. Aggressive: keep roughly the first half (rounded up).
if len(tokens) >= 7:
_add(' '.join(tokens[:len(tokens) // 2 + 1]))
return variants
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""
Search Tidal for tracks (async, Soulseek-compatible interface).
@ -255,21 +347,117 @@ class TidalDownloadClient:
logger.warning("Tidal not available for search (not authenticated)")
return ([], [])
# Defensive guard — None/empty query would blow up the shortener's
# .strip() call. Match the original behaviour (log + empty tuple).
if not query or not isinstance(query, str):
logger.warning(f"Invalid Tidal search query: {query!r}")
return ([], [])
logger.info(f"Searching Tidal for: {query}")
# Outer try/except preserves the original contract: any unexpected
# error returns ([], []) so the caller can fall back to other sources
# instead of crashing. Traceback is logged once, not per-attempt.
try:
# Build the retry ladder: original query, then progressively-shortened
# variants. Capped at 5 total requests to avoid hammering Tidal on
# genuinely-missing tracks, while still allowing enough variants to
# cover multi-word trailing modifiers like remix credits.
queries_to_try = [query] + self._generate_shortened_queries(query)
queries_to_try = queries_to_try[:5]
# Qualifier-aware safety net: if the original query contains variant
# keywords (Live, Remix, Acoustic, Extended, etc.), fallback results
# MUST still contain those qualifiers in their track names. Otherwise
# a shortened query could silently downgrade "Song (Live)" to the
# studio "Song" and the caller would download the wrong variant.
required_qualifiers = self._extract_qualifiers(query)
tidal_tracks: list = []
successful_query: Optional[str] = None
last_error: Optional[Exception] = None
# Tracks whether ANY fallback attempt returned broader matches that
# got rejected by the qualifier filter — used to give an accurate
# "no qualifier-matching variant" log message at the end instead of
# a generic "0 results".
any_fallback_filtered_out = False
loop = asyncio.get_event_loop()
for attempt_idx, attempt_query in enumerate(queries_to_try):
try:
q_copy = attempt_query
def _search():
results = self.session.search(query, models=[tidalapi.media.Track], limit=50)
return results.get('tracks', []) if isinstance(results, dict) else []
def _search(q=q_copy):
results = self.session.search(q, models=[tidalapi.media.Track], limit=50)
return results.get('tracks', []) if isinstance(results, dict) else []
tidal_tracks = await loop.run_in_executor(None, _search)
found = await loop.run_in_executor(None, _search)
if found:
# Fallback attempts get qualifier-filtered. We trust the
# original query to return only appropriate matches, but
# shortened queries are more permissive and can return
# wrong-variant tracks (e.g. studio when Live was asked
# for). Drop any result whose title doesn't carry all
# original qualifier words.
is_fallback = attempt_idx > 0
if is_fallback and required_qualifiers:
filtered = [
t for t in found
if self._track_name_contains_qualifiers(getattr(t, 'name', ''), required_qualifiers)
]
if filtered:
tidal_tracks = filtered
successful_query = attempt_query
logger.info(
f"Tidal fallback kept {len(filtered)}/{len(found)} tracks "
f"after qualifier filter {required_qualifiers} for '{attempt_query}'"
)
break
else:
any_fallback_filtered_out = True
logger.debug(
f"Tidal fallback '{attempt_query}' returned {len(found)} tracks "
f"but none matched original qualifiers {required_qualifiers}"
f"trying next variant"
)
if attempt_idx < len(queries_to_try) - 1:
await asyncio.sleep(0.1)
continue
else:
tidal_tracks = found
successful_query = attempt_query
break
if attempt_idx < len(queries_to_try) - 1:
logger.debug(f"Tidal returned 0 results for '{attempt_query}' — trying shortened variant")
# Small pause so we're not hammering Tidal with rapid retries
await asyncio.sleep(0.1)
except Exception as e:
last_error = e
logger.debug(f"Tidal search attempt {attempt_idx + 1} failed: {e}")
if not tidal_tracks:
logger.warning(f"No Tidal results for: {query}")
if last_error is not None:
import traceback
tb_str = ''.join(traceback.format_exception(
type(last_error), last_error, last_error.__traceback__
))
logger.error(
f"Tidal search failed after {len(queries_to_try)} attempts: {last_error}\n{tb_str}"
)
elif any_fallback_filtered_out:
logger.warning(
f"No Tidal results for '{query}' — fallbacks found broader matches but "
f"none preserved required qualifiers {required_qualifiers}"
)
else:
logger.warning(f"No Tidal results for: {query}")
return ([], [])
if successful_query and successful_query != query:
logger.info(f"Tidal fallback query succeeded: '{successful_query}' (original: '{query}')")
# Get configured quality for display
quality_key = config_manager.get('tidal_download.quality', 'lossless')
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
@ -286,7 +474,11 @@ class TidalDownloadClient:
return (track_results, [])
except Exception as e:
logger.error(f"Tidal search failed: {e}")
# Unhandled error in the retry orchestration itself (not in an
# individual attempt, which is already caught above). Preserves
# the original contract of returning ([], []) on any failure so
# the caller's fallback chain isn't broken.
logger.error(f"Tidal search orchestration failed: {e}")
import traceback
traceback.print_exc()
return ([], [])

View file

@ -0,0 +1,160 @@
"""Regression tests for TidalDownloadClient._generate_shortened_queries.
The shortener's job: when Tidal's search chokes on a long query with
qualifier suffixes ("... (Fred V Remix)"), produce progressively-shorter
variants so the retry loop has a chance of finding results. These tests
pin the expected retry ladder shape for common real-world query patterns.
"""
import sys
import types
# Stub tidalapi before importing the module — it uses tidalapi.Quality at
# import time, and we don't want to require the package for unit tests.
if 'tidalapi' not in sys.modules:
_fake = types.ModuleType('tidalapi')
class _FakeQuality:
low_96k = 'low_96k'
low_320k = 'low_320k'
high_lossless = 'high_lossless'
hi_res = 'hi_res'
hi_res_lossless = 'hi_res_lossless'
_fake.Quality = _FakeQuality
_fake.media = types.SimpleNamespace(Track=object)
sys.modules['tidalapi'] = _fake
from core.tidal_download_client import TidalDownloadClient # noqa: E402
def _ladder(original, cap=5):
"""Return the full retry ladder (original + variants), capped like the
runtime does."""
variants = TidalDownloadClient._generate_shortened_queries(original)
return [original] + variants[: cap - 1]
def test_skowl_reported_query_reaches_working_variant():
"""The user-reported failing query needs to reach
'maduk transformations remixed fire away' within the retry cap."""
original = 'maduk transformations remixed fire away fred v remix'
ladder = _ladder(original)
assert 'maduk transformations remixed fire away' in ladder
# And the original still starts the ladder
assert ladder[0] == original
def test_parenthesized_suffix_is_stripped_first():
"""The cheapest, most obvious shortening should come first for
parenthesized suffixes."""
variants = TidalDownloadClient._generate_shortened_queries('Song (Radio Edit)')
assert variants[0] == 'Song'
def test_bracketed_suffix_is_stripped():
variants = TidalDownloadClient._generate_shortened_queries('Song [Remix]')
assert 'Song' in variants
def test_short_queries_produce_no_variants():
# 1 or 2-word queries have nothing useful to shorten
assert TidalDownloadClient._generate_shortened_queries('one two') == []
assert TidalDownloadClient._generate_shortened_queries('single') == []
def test_variants_are_unique():
# Dedup guard — no variant should duplicate the original or another variant
original = 'Artist Title Club Mix Extended Version'
variants = TidalDownloadClient._generate_shortened_queries(original)
lower = [v.lower() for v in variants]
assert len(lower) == len(set(lower))
assert original.lower() not in lower
def test_progressive_drops_appear_in_ladder():
"""Drop-1, drop-2, drop-3 should all be present (in some order) for a
long query that has no parentheses."""
original = 'a b c d e f g h' # 8 tokens
variants = TidalDownloadClient._generate_shortened_queries(original)
# Drop-1 → 7 tokens; drop-2 → 6; drop-3 → 5
token_counts = [len(v.split()) for v in variants]
assert 7 in token_counts
assert 6 in token_counts
assert 5 in token_counts
def test_empty_query_returns_empty_list():
assert TidalDownloadClient._generate_shortened_queries('') == []
# ── Qualifier guard ────────────────────────────────────────────────────────
#
# When the original query carries a variant marker like "Live", "Remix",
# "Acoustic", fallback results must preserve that marker in the track name —
# otherwise a shortened query would silently downgrade "Song (Live)" to the
# studio "Song" and the caller would download the wrong variant.
def test_extract_qualifiers_finds_whole_word_matches():
# Word boundary: "remix" as a standalone word counts; "remixed" does not
q = TidalDownloadClient._extract_qualifiers(
'maduk transformations remixed fire away fred v remix'
)
assert 'remix' in q
# "mix" is inside "remix/remixed" but not a whole word
assert 'mix' not in q
def test_extract_qualifiers_is_case_insensitive():
q = TidalDownloadClient._extract_qualifiers('Song (LIVE at Wembley)')
assert 'live' in q
def test_extract_qualifiers_no_false_positives():
# "edit" must not match "edition"; "mix" must not match "remixed";
# "live" must not match "olive" / "deliver"
q = TidalDownloadClient._extract_qualifiers('Deluxe Edition Delivering Olive')
assert q == []
def test_extract_qualifiers_empty_query():
assert TidalDownloadClient._extract_qualifiers('') == []
assert TidalDownloadClient._extract_qualifiers(None) == []
def test_track_name_matches_when_all_qualifiers_present():
assert TidalDownloadClient._track_name_contains_qualifiers(
'Fire Away (Fred V Remix)', ['remix']
)
def test_track_name_rejects_when_qualifier_missing():
# Studio version should NOT pass when "remix" is required
assert not TidalDownloadClient._track_name_contains_qualifiers(
'Fire Away', ['remix']
)
def test_track_name_requires_all_qualifiers():
# "Live Acoustic" requires both
assert TidalDownloadClient._track_name_contains_qualifiers(
'Song (Live Acoustic)', ['live', 'acoustic']
)
# Missing one → rejected
assert not TidalDownloadClient._track_name_contains_qualifiers(
'Song (Live)', ['live', 'acoustic']
)
def test_track_name_empty_qualifiers_passes_everything():
# When no qualifiers required, any track passes (original-query behaviour)
assert TidalDownloadClient._track_name_contains_qualifiers('Anything', [])
def test_track_name_qualifier_is_word_bounded():
# "edit" qualifier must match "Edit" but not "Edition"
assert TidalDownloadClient._track_name_contains_qualifiers('Song (Radio Edit)', ['edit'])
assert not TidalDownloadClient._track_name_contains_qualifiers('Deluxe Edition', ['edit'])