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
This commit is contained in:
parent
a7ac4e48a4
commit
c97a072f54
3 changed files with 384 additions and 133 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue