From 0d0bbf38c972a13db2fb6fcb55e420e731e24b17 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Wed, 22 Apr 2026 07:42:16 -0700 Subject: [PATCH] Add query-shortening retry + qualifier guard to Tidal search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/tidal_download_client.py | 204 +++++++++++++++++++++++++- tests/test_tidal_search_shortening.py | 160 ++++++++++++++++++++ 2 files changed, 358 insertions(+), 6 deletions(-) create mode 100644 tests/test_tidal_search_shortening.py diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py index 951e10c9..31884e5c 100644 --- a/core/tidal_download_client.py +++ b/core/tidal_download_client.py @@ -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 ([], []) diff --git a/tests/test_tidal_search_shortening.py b/tests/test_tidal_search_shortening.py new file mode 100644 index 00000000..50920fad --- /dev/null +++ b/tests/test_tidal_search_shortening.py @@ -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'])