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:
parent
47ced912b9
commit
0d0bbf38c9
2 changed files with 358 additions and 6 deletions
|
|
@ -244,6 +244,98 @@ class TidalDownloadClient:
|
||||||
logger.error(f"Tidal connection check failed: {e}")
|
logger.error(f"Tidal connection check failed: {e}")
|
||||||
return False
|
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]]:
|
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).
|
Search Tidal for tracks (async, Soulseek-compatible interface).
|
||||||
|
|
@ -255,21 +347,117 @@ class TidalDownloadClient:
|
||||||
logger.warning("Tidal not available for search (not authenticated)")
|
logger.warning("Tidal not available for search (not authenticated)")
|
||||||
return ([], [])
|
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}")
|
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:
|
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()
|
loop = asyncio.get_event_loop()
|
||||||
|
for attempt_idx, attempt_query in enumerate(queries_to_try):
|
||||||
|
try:
|
||||||
|
q_copy = attempt_query
|
||||||
|
|
||||||
def _search():
|
def _search(q=q_copy):
|
||||||
results = self.session.search(query, models=[tidalapi.media.Track], limit=50)
|
results = self.session.search(q, models=[tidalapi.media.Track], limit=50)
|
||||||
return results.get('tracks', []) if isinstance(results, dict) else []
|
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:
|
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 ([], [])
|
return ([], [])
|
||||||
|
|
||||||
|
if successful_query and successful_query != query:
|
||||||
|
logger.info(f"Tidal fallback query succeeded: '{successful_query}' (original: '{query}')")
|
||||||
|
|
||||||
# Get configured quality for display
|
# Get configured quality for display
|
||||||
quality_key = config_manager.get('tidal_download.quality', 'lossless')
|
quality_key = config_manager.get('tidal_download.quality', 'lossless')
|
||||||
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
|
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
|
||||||
|
|
@ -286,7 +474,11 @@ class TidalDownloadClient:
|
||||||
return (track_results, [])
|
return (track_results, [])
|
||||||
|
|
||||||
except Exception as e:
|
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
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return ([], [])
|
return ([], [])
|
||||||
|
|
|
||||||
160
tests/test_tidal_search_shortening.py
Normal file
160
tests/test_tidal_search_shortening.py
Normal 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'])
|
||||||
Loading…
Reference in a new issue