From c97a072f544dc542e011b050f7741636a3cb50c5 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 30 Apr 2026 21:24:51 +0300 Subject: [PATCH] Refactor quality scanner to respect primary metadata provider - search metadata providers in source-priority order for each generated query instead of caching one client for the whole scan - keep the quality-scanner worker provider-neutral and preserve the no-provider error path - update the quality-scanner tests and remove the obsolete web_server spotify_client injection --- core/discovery/quality_scanner.py | 371 +++++++++++++----- .../test_discovery_quality_scanner.py | 145 +++++-- web_server.py | 1 - 3 files changed, 384 insertions(+), 133 deletions(-) diff --git a/core/discovery/quality_scanner.py b/core/discovery/quality_scanner.py index 0a0552f8..2d450293 100644 --- a/core/discovery/quality_scanner.py +++ b/core/discovery/quality_scanner.py @@ -3,7 +3,7 @@ `run_quality_scanner(scope, profile_id, deps)` is the function the quality-scanner endpoint kicks off in a thread to scan the library for low-quality tracks (below the user's configured quality profile) -and add their Spotify matches to the wishlist: +and add provider matches to the wishlist: 1. Reset scanner state, load quality profile + minimum acceptable tier. 2. Load tracks from DB based on scope: @@ -14,24 +14,16 @@ and add their Spotify matches to the wishlist: - Quality-tier check via _get_quality_tier_from_extension(file_path). - Skip tracks meeting standards (tier_num <= min_acceptable_tier). - For low-quality tracks: matching_engine search query gen, score - candidates against Spotify (artist + title similarity, album-type - bonus), pick best match >= 0.7 confidence. - - On match: add full Spotify track to wishlist via - `wishlist_service.add_spotify_track_to_wishlist` with + candidates against the configured metadata source priority + (artist + title similarity, album-type bonus), pick best match >= + 0.7 confidence. + - On match: add normalized track data to wishlist via + `wishlist_service.add_track_to_wishlist` with source_type='quality_scanner' and a source_context that captures original file_path, format tier, bitrate, and match confidence. 4. After all tracks: status='finished', progress=100, activity feed entry, emit `quality_scan_completed` event for automation engine. 5. On critical exception: status='error', error message captured. - -Note: This worker uses `wishlist_service` via its public -`add_spotify_track_to_wishlist` API only — it does not modify wishlist -internals. Safe to lift even before kettui's planned `core/wishlist/` -package extraction lands. - -Lifted verbatim from web_server.py. Wide dependency surface (Spotify -client, matching engine, automation engine, quality state and lock, -quality-tier helper) all injected via `QualityScannerDeps`. """ from __future__ import annotations @@ -42,6 +34,9 @@ from dataclasses import dataclass from datetime import datetime from typing import Any, Callable +from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority +from core.wishlist.payloads import ensure_wishlist_track_format + logger = logging.getLogger(__name__) @@ -51,13 +46,145 @@ class QualityScannerDeps: quality_scanner_state: dict quality_scanner_lock: Any # threading.Lock QUALITY_TIERS: dict - spotify_client: Any matching_engine: Any automation_engine: Any get_quality_tier_from_extension: Callable add_activity_item: Callable +def _extract_lookup_value(value: Any, *names: str, default: Any = None) -> Any: + if value is None: + return default + if isinstance(value, (str, bytes)): + return value + + for name in names: + if isinstance(value, dict): + if name in value and value[name] is not None: + return value[name] + else: + candidate = getattr(value, name, None) + if candidate is not None: + return candidate + return default + + +def _normalize_track_artists(track_item: Any) -> list[dict]: + artists = _extract_lookup_value(track_item, 'artists', default=[]) or [] + if isinstance(artists, (str, bytes)): + artists = [artists] + elif isinstance(artists, dict): + artists = [artists] + else: + try: + artists = list(artists) + except TypeError: + artists = [artists] + + normalized = [] + for artist in artists: + artist_name = _extract_lookup_value(artist, 'name', 'artist_name', 'title') + if not artist_name and isinstance(artist, (str, bytes)): + artist_name = artist + if artist_name: + normalized.append({'name': str(artist_name)}) + + if not normalized: + normalized.append({'name': 'Unknown Artist'}) + + return normalized + + +def _normalize_track_album(track_item: Any) -> dict: + album = _extract_lookup_value(track_item, 'album', default={}) + if isinstance(album, dict): + album_data = dict(album) + else: + album_data = { + 'name': _extract_lookup_value(album, 'name', 'title', default=str(album) if album else '') or '', + 'images': _extract_lookup_value(album, 'images', default=[]) or [], + 'album_type': _extract_lookup_value(album, 'album_type', default='album') or 'album', + 'total_tracks': _extract_lookup_value(album, 'total_tracks', 'track_count', default=0) or 0, + 'release_date': _extract_lookup_value(album, 'release_date', default='') or '', + } + + album_data.setdefault('name', _extract_lookup_value(track_item, 'album_name', default='Unknown Album') or 'Unknown Album') + album_data.setdefault('album_type', _extract_lookup_value(track_item, 'album_type', default='album') or 'album') + album_data.setdefault('total_tracks', _extract_lookup_value(track_item, 'total_tracks', 'track_count', default=0) or 0) + album_data.setdefault('release_date', _extract_lookup_value(track_item, 'release_date', default='') or '') + if isinstance(album, dict): + album_data.setdefault('images', album.get('images', []) or []) + else: + album_data.setdefault('images', []) + album_data.setdefault('artists', _normalize_track_artists(track_item)) + return album_data + + +def _normalize_track_match(track_item: Any, provider: str) -> dict: + track_data = { + 'id': _extract_lookup_value(track_item, 'id', 'track_id', default='') or '', + 'name': _extract_lookup_value(track_item, 'name', 'title', default='Unknown Track') or 'Unknown Track', + 'artists': _normalize_track_artists(track_item), + 'album': _normalize_track_album(track_item), + 'duration_ms': _extract_lookup_value(track_item, 'duration_ms', default=0) or 0, + 'track_number': _extract_lookup_value(track_item, 'track_number', default=1) or 1, + 'disc_number': _extract_lookup_value(track_item, 'disc_number', default=1) or 1, + 'preview_url': _extract_lookup_value(track_item, 'preview_url', default=None), + 'external_urls': _extract_lookup_value(track_item, 'external_urls', default={}) or {}, + 'popularity': _extract_lookup_value(track_item, 'popularity', default=0) or 0, + 'provider': provider, + 'source': provider, + } + return ensure_wishlist_track_format(track_data) + + +def _track_name(track_item: Any) -> str: + return str(_extract_lookup_value(track_item, 'name', 'title', default='Unknown Track') or 'Unknown Track') + + +def _track_artist_names(track_item: Any) -> list[str]: + artists = _extract_lookup_value(track_item, 'artists', default=[]) or [] + if isinstance(artists, (str, bytes)): + artists = [artists] + elif isinstance(artists, dict): + artists = [artists] + else: + try: + artists = list(artists) + except TypeError: + artists = [artists] + + normalized = [] + for artist in artists: + artist_name = _extract_lookup_value(artist, 'name', 'artist_name', 'title') + if not artist_name and isinstance(artist, (str, bytes)): + artist_name = artist + if artist_name: + normalized.append(str(artist_name)) + return normalized + + +def _search_tracks_for_source(source: str, query: str, limit: int = 5, client: Any = None): + if client is None: + client = get_client_for_source(source) + if not client or not hasattr(client, 'search_tracks'): + return [] + + try: + if source == 'spotify': + return client.search_tracks(query, limit=limit, allow_fallback=False) or [] + return client.search_tracks(query, limit=limit) or [] + except TypeError: + try: + return client.search_tracks(query, limit=limit) or [] + except Exception as exc: + logger.debug("Could not search %s for %s: %s", source, query, exc) + return [] + except Exception as exc: + logger.debug("Could not search %s for %s: %s", source, query, exc) + return [] + + def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDeps = None): """Main quality scanner worker function""" from core.wishlist_service import get_wishlist_service @@ -156,16 +283,23 @@ def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDep deps.quality_scanner_state["total"] = total_tracks deps.quality_scanner_state["phase"] = f"Scanning {total_tracks} tracks..." - # Use the module-level spotify_client (already authenticated with cached token) - if not deps.spotify_client or not deps.spotify_client.is_spotify_authenticated(): + source_priority = get_source_priority(get_primary_source()) + if not source_priority: with deps.quality_scanner_lock: deps.quality_scanner_state["status"] = "error" - deps.quality_scanner_state["phase"] = "Spotify not authenticated" - deps.quality_scanner_state["error_message"] = "Please authenticate with Spotify first" - logger.info("[Quality Scanner] Spotify not authenticated") + deps.quality_scanner_state["phase"] = "No metadata provider available" + deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning" + logger.info("[Quality Scanner] No metadata provider available") return + logger.info("[Quality Scanner] Using metadata source priority: %s", source_priority) + wishlist_service = get_wishlist_service() + add_to_wishlist = getattr(wishlist_service, 'add_track_to_wishlist', None) + if add_to_wishlist is None: + add_to_wishlist = getattr(wishlist_service, 'add_spotify_track_to_wishlist', None) + if add_to_wishlist is None: + raise AttributeError("Wishlist service does not expose an add-to-wishlist method") # Scan each track for idx, track_row in enumerate(tracks_to_scan, 1): @@ -199,9 +333,11 @@ def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDep logger.info(f"[Quality Scanner] Low quality: {artist_name} - {title} ({tier_name}, {file_path})") - # Attempt to match to Spotify using matching_engine + # Attempt to match using the active metadata provider matched = False matched_track_data = None + best_source = None + attempted_any_provider = False try: # Generate search queries using matching engine @@ -221,83 +357,99 @@ def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDep for _query_idx, search_query in enumerate(search_queries): try: - spotify_matches = deps.spotify_client.search_tracks(search_query, limit=5) - time.sleep(0.5) # Rate limit Spotify API calls - - if not spotify_matches: - continue - - # Score each result using matching engine - for spotify_track in spotify_matches: - try: - # Calculate artist confidence - artist_confidence = 0.0 - if spotify_track.artists: - for result_artist in spotify_track.artists: - artist_sim = deps.matching_engine.similarity_score( - deps.matching_engine.normalize_string(artist_name), - deps.matching_engine.normalize_string(result_artist) - ) - artist_confidence = max(artist_confidence, artist_sim) - - # Calculate title confidence - title_confidence = deps.matching_engine.similarity_score( - deps.matching_engine.normalize_string(title), - deps.matching_engine.normalize_string(spotify_track.name) - ) - - # Combined confidence (50% artist + 50% title) - combined_confidence = (artist_confidence * 0.5 + title_confidence * 0.5) - - # Small bonus for album tracks over singles - _at = getattr(spotify_track, 'album_type', None) or '' - if _at == 'album': - combined_confidence += 0.02 - elif _at == 'ep': - combined_confidence += 0.01 - - logger.info(f"[Quality Scanner] Candidate: '{spotify_track.artists[0]}' - '{spotify_track.name}' (confidence: {combined_confidence:.3f})") - - # Update best match if this is better - if combined_confidence > best_confidence and combined_confidence >= min_confidence: - best_confidence = combined_confidence - best_match = spotify_track - logger.info(f"[Quality Scanner] New best match: {spotify_track.artists[0]} - {spotify_track.name} (confidence: {combined_confidence:.3f})") - - except Exception as e: - logger.error(f"[Quality Scanner] Error scoring result: {e}") + for source in source_priority: + client = get_client_for_source(source) + if not client or not hasattr(client, 'search_tracks'): continue - # If we found a very high confidence match, stop searching - if best_confidence >= 0.9: - logger.info(f"[Quality Scanner] High confidence match found ({best_confidence:.3f}), stopping search") - break + attempted_any_provider = True + provider_matches = _search_tracks_for_source(source, search_query, limit=5, client=client) + time.sleep(0.5) # Rate limit metadata API calls + + if not provider_matches: + continue + + # Score each result using matching engine + for provider_track in provider_matches: + try: + # Calculate artist confidence + artist_confidence = 0.0 + provider_artists = _track_artist_names(provider_track) + if provider_artists: + for result_artist in provider_artists: + artist_sim = deps.matching_engine.similarity_score( + deps.matching_engine.normalize_string(artist_name), + deps.matching_engine.normalize_string(result_artist) + ) + artist_confidence = max(artist_confidence, artist_sim) + + # Calculate title confidence + title_confidence = deps.matching_engine.similarity_score( + deps.matching_engine.normalize_string(title), + deps.matching_engine.normalize_string(_track_name(provider_track)) + ) + + # Combined confidence (50% artist + 50% title) + combined_confidence = (artist_confidence * 0.5 + title_confidence * 0.5) + + # Small bonus for album tracks over singles + _at = _extract_lookup_value(provider_track, 'album_type', default='') or '' + if _at == 'album': + combined_confidence += 0.02 + elif _at == 'ep': + combined_confidence += 0.01 + + candidate_artist = provider_artists[0] if provider_artists else 'Unknown Artist' + candidate_name = _track_name(provider_track) + logger.info( + f"[Quality Scanner] Candidate ({source}): '{candidate_artist}' - " + f"'{candidate_name}' (confidence: {combined_confidence:.3f})" + ) + + # Update best match if this is better + if combined_confidence > best_confidence and combined_confidence >= min_confidence: + best_confidence = combined_confidence + best_match = provider_track + best_source = source + logger.info( + f"[Quality Scanner] New best match ({source}): {candidate_artist} - " + f"{candidate_name} (confidence: {combined_confidence:.3f})" + ) + + except Exception as e: + logger.error(f"[Quality Scanner] Error scoring result: {e}") + continue + + # If we found a very high confidence match, stop searching this query + if best_confidence >= 0.9: + logger.info(f"[Quality Scanner] High confidence match found ({best_confidence:.3f}), stopping search") + break except Exception as e: logger.debug(f"[Quality Scanner] Error searching with query '{search_query}': {e}") continue + if not attempted_any_provider: + with deps.quality_scanner_lock: + deps.quality_scanner_state["status"] = "error" + deps.quality_scanner_state["phase"] = "No metadata provider available" + deps.quality_scanner_state["error_message"] = "No metadata provider is available for quality scanning" + logger.info("[Quality Scanner] No metadata provider available") + return + # Process best match if best_match: matched = True - logger.info(f"[Quality Scanner] Final match: {best_match.artists[0]} - {best_match.name} (confidence: {best_confidence:.3f})") + final_artist = _track_artist_names(best_match)[0] if _track_artist_names(best_match) else 'Unknown Artist' + final_name = _track_name(best_match) + final_source = best_source or 'metadata' + logger.info( + f"[Quality Scanner] Final match ({final_source}): {final_artist} - " + f"{final_name} (confidence: {best_confidence:.3f})" + ) - # Build full Spotify track data for wishlist - matched_track_data = { - 'id': best_match.id, - 'name': best_match.name, - 'artists': [{'name': artist} for artist in best_match.artists], - 'album': { - 'name': best_match.album, - 'artists': [{'name': artist} for artist in best_match.artists], - 'album_type': 'album', # Default to 'album' for quality scanner matches - 'release_date': getattr(best_match, 'release_date', '') or '' - }, - 'duration_ms': best_match.duration_ms, - 'popularity': best_match.popularity, - 'preview_url': best_match.preview_url, - 'external_urls': best_match.external_urls or {} - } + # Build normalized track data for wishlist + matched_track_data = _normalize_track_match(best_match, final_source) # Add to wishlist source_context = { @@ -309,8 +461,8 @@ def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDep 'scan_date': datetime.now().isoformat() } - success = wishlist_service.add_spotify_track_to_wishlist( - spotify_track_data=matched_track_data, + success = add_to_wishlist( + track_data=matched_track_data, failure_reason=f"Low quality - {tier_name.replace('_', ' ').title()} format", source_type='quality_scanner', source_context=source_context, @@ -324,29 +476,34 @@ def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDep else: logger.error(f"[Quality Scanner] Failed to add to wishlist: {artist_name} - {title}") else: - logger.warning(f"[Quality Scanner] No suitable match found (best confidence: {best_confidence:.3f}, required: {min_confidence:.3f})") + logger.warning( + f"[Quality Scanner] No suitable metadata match found " + f"(best confidence: {best_confidence:.3f}, required: {min_confidence:.3f})" + ) except Exception as matching_error: logger.error(f"[Quality Scanner] Matching error for {artist_name} - {title}: {matching_error}") - # Store result - result_entry = { - 'track_id': track_id, - 'title': title, - 'artist': artist_name, - 'album': album_title, - 'file_path': file_path, - 'current_format': tier_name, - 'bitrate': bitrate, - 'matched': matched, - 'spotify_id': matched_track_data['id'] if matched_track_data else None - } + # Store result + result_entry = { + 'track_id': track_id, + 'title': title, + 'artist': artist_name, + 'album': album_title, + 'file_path': file_path, + 'current_format': tier_name, + 'bitrate': bitrate, + 'matched': matched, + 'match_id': matched_track_data['id'] if matched_track_data else None, + 'provider': best_source if matched else None, + 'spotify_id': matched_track_data['id'] if matched_track_data else None, + } - with deps.quality_scanner_lock: - deps.quality_scanner_state["results"].append(result_entry) + with deps.quality_scanner_lock: + deps.quality_scanner_state["results"].append(result_entry) - if not matched: - logger.warning(f"[Quality Scanner] No Spotify match found for: {artist_name} - {title}") + if not matched: + logger.warning(f"[Quality Scanner] No metadata match found for: {artist_name} - {title}") except Exception as track_error: logger.error(f"[Quality Scanner] Error processing track: {track_error}") @@ -361,7 +518,7 @@ def run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDep deps.quality_scanner_state["phase"] = "Scan complete" logger.info(f"[Quality Scanner] Scan {'stopped' if was_stopped else 'complete'}: {deps.quality_scanner_state['processed']} processed, " - f"{deps.quality_scanner_state['low_quality']} low quality, {deps.quality_scanner_state['matched']} matched to Spotify") + f"{deps.quality_scanner_state['low_quality']} low quality, {deps.quality_scanner_state['matched']} matched to metadata providers") # Add activity deps.add_activity_item("", "Quality Scan Complete", diff --git a/tests/discovery/test_discovery_quality_scanner.py b/tests/discovery/test_discovery_quality_scanner.py index 6c1c7301..9a383c0f 100644 --- a/tests/discovery/test_discovery_quality_scanner.py +++ b/tests/discovery/test_discovery_quality_scanner.py @@ -4,7 +4,6 @@ from __future__ import annotations import threading from dataclasses import dataclass -from unittest.mock import MagicMock import pytest @@ -35,20 +34,30 @@ class _FakeSpotifyTrack: self.external_urls = {} -class _FakeSpotifyClient: - def __init__(self, results=None, authenticated=True): +class _FakeMetadataClient: + def __init__(self, results=None): self._results = results if results is not None else [] - self._authenticated = authenticated self.search_calls = [] - def is_spotify_authenticated(self): - return self._authenticated - - def search_tracks(self, query, limit=5): - self.search_calls.append((query, limit)) + def search_tracks(self, query, limit=5, allow_fallback=True): + self.search_calls.append((query, limit, allow_fallback)) return self._results +_TEST_PRIMARY_SOURCE = 'spotify' +_TEST_SOURCE_CLIENTS = {} + + +@pytest.fixture(autouse=True) +def _patch_source_resolution(monkeypatch): + monkeypatch.setattr(qs, 'get_primary_source', lambda: _TEST_PRIMARY_SOURCE) + monkeypatch.setattr(qs, 'get_client_for_source', lambda source, **_kwargs: _TEST_SOURCE_CLIENTS.get(source)) + monkeypatch.setattr(qs.time, 'sleep', lambda *_args, **_kwargs: None) + yield + _TEST_SOURCE_CLIENTS.clear() + globals()['_TEST_PRIMARY_SOURCE'] = 'spotify' + + class _FakeMatchingEngine: def generate_download_queries(self, t): return [f"{t.artists[0]} {t.name}"] @@ -64,6 +73,14 @@ class _FakeMatchingEngine: return 0.95 if a in b or b in a else 0.0 +class _MultiQueryMatchingEngine(_FakeMatchingEngine): + def generate_download_queries(self, t): + return [ + f"{t.artists[0]} {t.name} first", + f"{t.artists[0]} {t.name} second", + ] + + class _FakeAutomationEngine: def __init__(self): self.events = [] @@ -125,16 +142,22 @@ class _WatchlistArtist: def _build_deps( *, state=None, - spotify_results=None, - spotify_auth=True, + source_clients=None, + primary_source='spotify', quality_tier_result=('lossless', 1), automation=None, ): + globals()['_TEST_PRIMARY_SOURCE'] = primary_source + _TEST_SOURCE_CLIENTS.clear() + if source_clients is not None: + _TEST_SOURCE_CLIENTS.update(source_clients) + elif primary_source: + _TEST_SOURCE_CLIENTS[primary_source] = _FakeMetadataClient(results=[]) + deps = qs.QualityScannerDeps( quality_scanner_state=state if state is not None else {}, quality_scanner_lock=threading.Lock(), QUALITY_TIERS={'lossless': {'tier': 1}, 'low_lossy': {'tier': 4}, 'lossy': {'tier': 3}}, - spotify_client=_FakeSpotifyClient(results=spotify_results or [], authenticated=spotify_auth), matching_engine=_FakeMatchingEngine(), automation_engine=automation or _FakeAutomationEngine(), get_quality_tier_from_extension=lambda fp: quality_tier_result, @@ -190,21 +213,21 @@ def test_no_watchlist_artists_short_circuit(mock_db_and_wishlist): # --------------------------------------------------------------------------- -# Spotify auth gate +# Provider availability gate # --------------------------------------------------------------------------- -def test_unauthenticated_spotify_marks_error(mock_db_and_wishlist): - """Spotify not authenticated → state['status']='error'.""" +def test_no_available_provider_marks_error(mock_db_and_wishlist): + """No available metadata providers → state['status']='error'.""" db, _ = mock_db_and_wishlist db._watchlist_artists = [_WatchlistArtist('A')] db._tracks = [_track_row()] state = {} - deps = _build_deps(state=state, spotify_auth=False) + deps = _build_deps(state=state, source_clients={}, quality_tier_result=('low_lossy', 4)) qs.run_quality_scanner('watchlist', 1, deps) assert state['status'] == 'error' - assert 'authenticate' in state['error_message'].lower() + assert 'metadata provider' in state['error_message'].lower() # --------------------------------------------------------------------------- @@ -228,19 +251,83 @@ def test_high_quality_tracks_skipped(mock_db_and_wishlist): def test_low_quality_tracks_attempted(mock_db_and_wishlist): - """Low-quality tracks (tier_num > min) trigger Spotify search.""" + """Low-quality tracks (tier_num > min) trigger a metadata search.""" db, _ = mock_db_and_wishlist db._watchlist_artists = [_WatchlistArtist('Artist')] db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')] state = {} match = _FakeSpotifyTrack(name='Track', artists=['Artist']) - deps = _build_deps(state=state, quality_tier_result=('low_lossy', 4), - spotify_results=[match]) + spotify_client = _FakeMetadataClient(results=[match]) + deps = _build_deps( + state=state, + quality_tier_result=('low_lossy', 4), + source_clients={'spotify': spotify_client}, + primary_source='spotify', + ) qs.run_quality_scanner('watchlist', 1, deps) assert state['low_quality'] == 1 - assert deps.spotify_client.search_calls # spotify queried + assert spotify_client.search_calls + assert spotify_client.search_calls[0][2] is False + + +def test_client_lookup_happens_per_query(mock_db_and_wishlist, monkeypatch): + """Each generated query re-resolves the source client.""" + db, _ = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('Artist')] + db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')] + state = {} + spotify_client = _FakeMetadataClient(results=[]) + lookups = [] + + monkeypatch.setattr( + qs, + 'get_client_for_source', + lambda source, **_kwargs: (lookups.append(source), _TEST_SOURCE_CLIENTS.get(source))[1], + ) + + deps = _build_deps( + state=state, + quality_tier_result=('low_lossy', 4), + source_clients={'spotify': spotify_client}, + primary_source='spotify', + ) + deps.matching_engine = _MultiQueryMatchingEngine() + + qs.run_quality_scanner('watchlist', 1, deps) + + assert len(lookups) % 2 == 0 + midpoint = len(lookups) // 2 + assert lookups[:midpoint] == lookups[midpoint:] + assert len(spotify_client.search_calls) == 2 + + +def test_low_quality_tracks_follow_source_priority(mock_db_and_wishlist): + """Primary source is searched before Spotify.""" + db, ws = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('Artist')] + db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')] + state = {} + match = _FakeSpotifyTrack(name='Track', artists=['Artist']) + deezer_client = _FakeMetadataClient(results=[match]) + spotify_client = _FakeMetadataClient(results=[]) + deps = _build_deps( + state=state, + source_clients={'deezer': deezer_client, 'spotify': spotify_client}, + primary_source='deezer', + quality_tier_result=('low_lossy', 4), + ) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['matched'] == 1 + assert deezer_client.search_calls + assert spotify_client.search_calls == [] + assert len(ws.added) == 1 + add_args = ws.added[0] + assert add_args['track_data']['provider'] == 'deezer' + assert add_args['track_data']['source'] == 'deezer' # --------------------------------------------------------------------------- @@ -254,8 +341,12 @@ def test_match_adds_to_wishlist(mock_db_and_wishlist): db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)] state = {} match = _FakeSpotifyTrack(name='Track', artists=['Artist']) - deps = _build_deps(state=state, quality_tier_result=('low_lossy', 4), - spotify_results=[match]) + deps = _build_deps( + state=state, + quality_tier_result=('low_lossy', 4), + source_clients={'spotify': _FakeMetadataClient(results=[match])}, + primary_source='spotify', + ) qs.run_quality_scanner('watchlist', 1, deps) @@ -273,8 +364,12 @@ def test_no_match_no_wishlist_add(mock_db_and_wishlist): db._tracks = [_track_row(artist_name='A', title='Z', file_path='/x.mp3')] state = {} # No spotify results → no match - deps = _build_deps(state=state, quality_tier_result=('low_lossy', 4), - spotify_results=[]) + deps = _build_deps( + state=state, + quality_tier_result=('low_lossy', 4), + source_clients={'spotify': _FakeMetadataClient(results=[])}, + primary_source='spotify', + ) qs.run_quality_scanner('watchlist', 1, deps) diff --git a/web_server.py b/web_server.py index 136b26a2..87b8bbaa 100644 --- a/web_server.py +++ b/web_server.py @@ -16563,7 +16563,6 @@ def _build_quality_scanner_deps(): quality_scanner_state=quality_scanner_state, quality_scanner_lock=quality_scanner_lock, QUALITY_TIERS=QUALITY_TIERS, - spotify_client=spotify_client, matching_engine=matching_engine, automation_engine=automation_engine, get_quality_tier_from_extension=_get_quality_tier_from_extension,