From a38bfcba55d8a54ac3a94c7ad580cbb86353f8c0 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:41:29 -0700 Subject: [PATCH] PR5h: lift _run_quality_scanner to core/discovery/quality_scanner.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final lift in the PR5 discovery-workers series. Pulls the 328-line library quality scanner out of `web_server.py` into its own focused module under `core/discovery/`. Pure 1:1 lift — wrapper keeps the original entry-point name. What the quality scanner does: 1. Reset scanner state (counters, results), load quality profile + minimum acceptable tier from QUALITY_TIERS. 2. Load tracks from DB based on scope: - 'watchlist' → tracks for watchlisted artists only. - other → all library tracks. 3. For each track: - Stop-request gate (state['status'] != 'running'). - 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 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. Wishlist service interaction is via the public `add_spotify_track_to_wishlist` API only — no overlap with kettui's planned `core/wishlist/` package extraction (the import lives inside the function, exactly as in the original, and will follow whatever path that package takes). Dependencies injected via `QualityScannerDeps` (8 fields) — quality_scanner_state dict, quality_scanner_lock, QUALITY_TIERS constant, spotify_client, matching_engine, automation_engine, plus 2 callable helpers (get_quality_tier_from_extension, add_activity_item). Diff vs original after `deps.X` → global X normalization is **zero differences** — 328 lines orig = 328 lines lifted, byte-identical body (including all whitespace, comments, log strings, and the inline `from core.wishlist_service import get_wishlist_service` / `from database.music_database import MusicDatabase` imports at the top of the function). Tests: 11 new under tests/discovery/test_discovery_quality_scanner.py covering state init/reset, no-watchlist-artists short-circuit, unauthenticated Spotify error, high-quality skip, low-quality search trigger, match → wishlist add (with full source_context payload), no-match no-add, mid-loop stop request, completion phase + progress, automation engine event emission, all-library scope load. Full suite: 1152 passing (was 1141). Ruff clean. End of the PR5 series — `web_server.py` lost ~328 lines on this commit alone; total trim across PR5a–PR5h is ~2,400 lines of discovery worker code moved into focused `core/discovery/*.py` modules. The remaining discovery-adjacent worker `_process_watchlist_scan_automatically` was deliberately deferred to avoid overlap with kettui's planned wishlist extraction. --- core/discovery/quality_scanner.py | 388 ++++++++++++++++++ .../test_discovery_quality_scanner.py | 361 ++++++++++++++++ web_server.py | 348 +--------------- 3 files changed, 770 insertions(+), 327 deletions(-) create mode 100644 core/discovery/quality_scanner.py create mode 100644 tests/discovery/test_discovery_quality_scanner.py diff --git a/core/discovery/quality_scanner.py b/core/discovery/quality_scanner.py new file mode 100644 index 00000000..0a0552f8 --- /dev/null +++ b/core/discovery/quality_scanner.py @@ -0,0 +1,388 @@ +"""Background worker for the library quality scanner. + +`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: + +1. Reset scanner state, load quality profile + minimum acceptable tier. +2. Load tracks from DB based on scope: + - 'watchlist' → tracks for watchlisted artists only. + - other → all library tracks. +3. For each track: + - Stop-request gate (state['status'] != 'running'). + - 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 + 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 + +import logging +import time +from dataclasses import dataclass +from datetime import datetime +from typing import Any, Callable + +logger = logging.getLogger(__name__) + + +@dataclass +class QualityScannerDeps: + """Bundle of cross-cutting deps the quality scanner needs.""" + 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 run_quality_scanner(scope='watchlist', profile_id=1, deps: QualityScannerDeps = None): + """Main quality scanner worker function""" + from core.wishlist_service import get_wishlist_service + from database.music_database import MusicDatabase + + try: + with deps.quality_scanner_lock: + deps.quality_scanner_state["status"] = "running" + deps.quality_scanner_state["phase"] = "Initializing scan..." + deps.quality_scanner_state["progress"] = 0 + deps.quality_scanner_state["processed"] = 0 + deps.quality_scanner_state["total"] = 0 + deps.quality_scanner_state["quality_met"] = 0 + deps.quality_scanner_state["low_quality"] = 0 + deps.quality_scanner_state["matched"] = 0 + deps.quality_scanner_state["results"] = [] + deps.quality_scanner_state["error_message"] = "" + + logger.info(f"[Quality Scanner] Starting scan with scope: {scope}") + + # Get database instance + db = MusicDatabase() + + # Get quality profile to determine preferred quality + quality_profile = db.get_quality_profile() + preferred_qualities = quality_profile.get('qualities', {}) + + # Determine minimum acceptable tier based on enabled qualities + min_acceptable_tier = 999 + for quality_name, quality_config in preferred_qualities.items(): + if quality_config.get('enabled', False): + # Map quality profile names to tier names + tier_map = { + 'flac': 'lossless', + 'mp3_320': 'low_lossy', + 'mp3_256': 'low_lossy', + 'mp3_192': 'low_lossy' + } + tier_name = tier_map.get(quality_name) + if tier_name: + tier_num = deps.QUALITY_TIERS[tier_name]['tier'] + min_acceptable_tier = min(min_acceptable_tier, tier_num) + + logger.info(f"[Quality Scanner] Minimum acceptable tier: {min_acceptable_tier}") + + # Get tracks to scan based on scope + with deps.quality_scanner_lock: + deps.quality_scanner_state["phase"] = "Loading tracks from database..." + + if scope == 'watchlist': + # Get watchlist artists + watchlist_artists = db.get_watchlist_artists(profile_id=profile_id) + if not watchlist_artists: + with deps.quality_scanner_lock: + deps.quality_scanner_state["status"] = "finished" + deps.quality_scanner_state["phase"] = "No watchlist artists found" + deps.quality_scanner_state["error_message"] = "Please add artists to watchlist first" + logger.warning("[Quality Scanner] No watchlist artists found") + return + + # Get artist names from watchlist + artist_names = [artist.artist_name for artist in watchlist_artists] + logger.info(f"[Quality Scanner] Scanning {len(artist_names)} watchlist artists") + + # Get all tracks for these artists by name + conn = db._get_connection() + placeholders = ','.join(['?' for _ in artist_names]) + tracks_to_scan = conn.execute( + f"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title " + f"FROM tracks t " + f"JOIN artists a ON t.artist_id = a.id " + f"JOIN albums al ON t.album_id = al.id " + f"WHERE a.name IN ({placeholders}) AND t.file_path IS NOT NULL", + artist_names + ).fetchall() + conn.close() + else: + # Scan all library tracks + with deps.quality_scanner_lock: + deps.quality_scanner_state["phase"] = "Loading all library tracks..." + + conn = db._get_connection() + tracks_to_scan = conn.execute( + "SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title " + "FROM tracks t " + "JOIN artists a ON t.artist_id = a.id " + "JOIN albums al ON t.album_id = al.id " + "WHERE t.file_path IS NOT NULL" + ).fetchall() + conn.close() + + total_tracks = len(tracks_to_scan) + logger.info(f"[Quality Scanner] Found {total_tracks} tracks to scan") + + with deps.quality_scanner_lock: + 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(): + 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") + return + + wishlist_service = get_wishlist_service() + + # Scan each track + for idx, track_row in enumerate(tracks_to_scan, 1): + # Check for stop request + if deps.quality_scanner_state.get('status') != 'running': + logger.info(f"[Quality Scanner] Stop requested, halting at track {idx}/{total_tracks}") + break + + try: + track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title = track_row + + # Check quality tier + tier_name, tier_num = deps.get_quality_tier_from_extension(file_path) + + # Update progress + with deps.quality_scanner_lock: + deps.quality_scanner_state["processed"] = idx + deps.quality_scanner_state["progress"] = (idx / total_tracks) * 100 + deps.quality_scanner_state["phase"] = f"Scanning: {artist_name} - {title}" + + # Check if meets quality standards + if tier_num <= min_acceptable_tier: + # Quality met + with deps.quality_scanner_lock: + deps.quality_scanner_state["quality_met"] += 1 + continue + + # Low quality track found + with deps.quality_scanner_lock: + deps.quality_scanner_state["low_quality"] += 1 + + logger.info(f"[Quality Scanner] Low quality: {artist_name} - {title} ({tier_name}, {file_path})") + + # Attempt to match to Spotify using matching_engine + matched = False + matched_track_data = None + + try: + # Generate search queries using matching engine + temp_track = type('TempTrack', (), { + 'name': title, + 'artists': [artist_name], + 'album': album_title + })() + + search_queries = deps.matching_engine.generate_download_queries(temp_track) + logger.info(f"[Quality Scanner] Generated {len(search_queries)} search queries for {artist_name} - {title}") + + # Find best match using confidence scoring + best_match = None + best_confidence = 0.0 + min_confidence = 0.7 # Match existing standard + + 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}") + 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 + + except Exception as e: + logger.debug(f"[Quality Scanner] Error searching with query '{search_query}': {e}") + continue + + # 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})") + + # 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 {} + } + + # Add to wishlist + source_context = { + 'quality_scanner': True, + 'original_file_path': file_path, + 'original_format': tier_name, + 'original_bitrate': bitrate, + 'match_confidence': best_confidence, + 'scan_date': datetime.now().isoformat() + } + + success = wishlist_service.add_spotify_track_to_wishlist( + spotify_track_data=matched_track_data, + failure_reason=f"Low quality - {tier_name.replace('_', ' ').title()} format", + source_type='quality_scanner', + source_context=source_context, + profile_id=profile_id + ) + + if success: + with deps.quality_scanner_lock: + deps.quality_scanner_state["matched"] += 1 + logger.info(f"[Quality Scanner] Matched and added to wishlist: {artist_name} - {title}") + 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})") + + 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 + } + + 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}") + + except Exception as track_error: + logger.error(f"[Quality Scanner] Error processing track: {track_error}") + continue + + # Scan complete (don't overwrite if already stopped by user) + with deps.quality_scanner_lock: + was_stopped = deps.quality_scanner_state["status"] != "running" + deps.quality_scanner_state["status"] = "finished" + deps.quality_scanner_state["progress"] = 100 + if not was_stopped: + 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") + + # Add activity + deps.add_activity_item("", "Quality Scan Complete", + f"{deps.quality_scanner_state['matched']} tracks added to wishlist", "Now") + + try: + if deps.automation_engine: + deps.automation_engine.emit('quality_scan_completed', { + 'quality_met': str(deps.quality_scanner_state.get('quality_met', 0)), + 'low_quality': str(deps.quality_scanner_state.get('low_quality', 0)), + 'total_scanned': str(deps.quality_scanner_state.get('processed', 0)), + }) + except Exception: + pass + + except Exception as e: + logger.error(f"[Quality Scanner] Critical error: {e}") + import traceback + traceback.print_exc() + + with deps.quality_scanner_lock: + deps.quality_scanner_state["status"] = "error" + deps.quality_scanner_state["error_message"] = str(e) + deps.quality_scanner_state["phase"] = f"Error: {str(e)}" diff --git a/tests/discovery/test_discovery_quality_scanner.py b/tests/discovery/test_discovery_quality_scanner.py new file mode 100644 index 00000000..6c1c7301 --- /dev/null +++ b/tests/discovery/test_discovery_quality_scanner.py @@ -0,0 +1,361 @@ +"""Tests for core/discovery/quality_scanner.py — library quality scanner.""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from unittest.mock import MagicMock + +import pytest + +from core.discovery import quality_scanner as qs + + +# --------------------------------------------------------------------------- +# Fakes +# --------------------------------------------------------------------------- + +@dataclass +class _FakeSpotifyTrack: + id: str = 'spt-1' + name: str = 'Found' + artists: list = None + album: str = 'Found Album' + duration_ms: int = 200000 + popularity: int = 50 + preview_url: str = '' + external_urls: dict = None + album_type: str = 'album' + release_date: str = '2024-01-01' + + def __post_init__(self): + if self.artists is None: + self.artists = ['Found Artist'] + if self.external_urls is None: + self.external_urls = {} + + +class _FakeSpotifyClient: + def __init__(self, results=None, authenticated=True): + 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)) + return self._results + + +class _FakeMatchingEngine: + def generate_download_queries(self, t): + return [f"{t.artists[0]} {t.name}"] + + def normalize_string(self, s): + return (s or '').lower().strip() + + def similarity_score(self, a, b): + if a == b: + return 1.0 + if not a or not b: + return 0.0 + return 0.95 if a in b or b in a else 0.0 + + +class _FakeAutomationEngine: + def __init__(self): + self.events = [] + + def emit(self, event_type, data): + self.events.append((event_type, data)) + + +class _FakeWishlistService: + def __init__(self): + self.added = [] + + def add_spotify_track_to_wishlist(self, **kwargs): + self.added.append(kwargs) + return True + + +class _FakeMusicDB: + def __init__(self, watchlist_artists=None, tracks=None, profile=None): + self._watchlist_artists = watchlist_artists if watchlist_artists is not None else [] + self._tracks = tracks if tracks is not None else [] + self._profile = profile or {'qualities': {'flac': {'enabled': True}}} + + def get_quality_profile(self): + return self._profile + + def get_watchlist_artists(self, profile_id=1): + return self._watchlist_artists + + def _get_connection(self): + rows = self._tracks + return _FakeConn(rows) + + +class _FakeConn: + def __init__(self, rows): + self._rows = rows + + def execute(self, query, params=None): + return _FakeCursor(self._rows) + + def close(self): + pass + + +class _FakeCursor: + def __init__(self, rows): + self._rows = rows + + def fetchall(self): + return self._rows + + +@dataclass +class _WatchlistArtist: + artist_name: str + + +def _build_deps( + *, + state=None, + spotify_results=None, + spotify_auth=True, + quality_tier_result=('lossless', 1), + automation=None, +): + 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, + add_activity_item=lambda *a, **kw: None, + ) + return deps + + +def _track_row(track_id=1, title='Track', artist_id=1, album_id=1, + file_path='/x.mp3', bitrate=128, artist_name='Artist', + album_title='Album'): + return (track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title) + + +@pytest.fixture +def mock_db_and_wishlist(monkeypatch): + """Patches MusicDatabase and get_wishlist_service used inside the worker.""" + db = _FakeMusicDB() + ws = _FakeWishlistService() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + monkeypatch.setattr('core.wishlist_service.get_wishlist_service', lambda: ws) + return db, ws + + +# --------------------------------------------------------------------------- +# State init + DB load +# --------------------------------------------------------------------------- + +def test_state_initialized_on_run(mock_db_and_wishlist): + """Scanner resets state to running with cleared counters.""" + db, _ = mock_db_and_wishlist + db._watchlist_artists = [] # no artists → exits early but after init + state = {} + deps = _build_deps(state=state) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['status'] == 'finished' # exited early since no artists + assert state['error_message'] == 'Please add artists to watchlist first' + + +def test_no_watchlist_artists_short_circuit(mock_db_and_wishlist): + """Scope=watchlist with no artists → status=finished, error message.""" + db, _ = mock_db_and_wishlist + db._watchlist_artists = [] + state = {} + deps = _build_deps(state=state) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['status'] == 'finished' + assert 'add artists' in state['error_message'] + + +# --------------------------------------------------------------------------- +# Spotify auth gate +# --------------------------------------------------------------------------- + +def test_unauthenticated_spotify_marks_error(mock_db_and_wishlist): + """Spotify not authenticated → 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) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['status'] == 'error' + assert 'authenticate' in state['error_message'].lower() + + +# --------------------------------------------------------------------------- +# Quality tier check + skip +# --------------------------------------------------------------------------- + +def test_high_quality_tracks_skipped(mock_db_and_wishlist): + """Tracks meeting quality (tier_num <= min_acceptable) → quality_met += 1.""" + db, _ = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('A')] + db._tracks = [_track_row(file_path='/x.flac')] + state = {} + # Default min_acceptable is from {flac: enabled} → tier 1 (lossless) + # quality_tier_result=('lossless', 1) → 1 <= 1 → skip + deps = _build_deps(state=state, quality_tier_result=('lossless', 1)) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['quality_met'] == 1 + assert state['low_quality'] == 0 + + +def test_low_quality_tracks_attempted(mock_db_and_wishlist): + """Low-quality tracks (tier_num > min) trigger Spotify 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]) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['low_quality'] == 1 + assert deps.spotify_client.search_calls # spotify queried + + +# --------------------------------------------------------------------------- +# Match → wishlist add +# --------------------------------------------------------------------------- + +def test_match_adds_to_wishlist(mock_db_and_wishlist): + """High-confidence match → wishlist_service.add_spotify_track_to_wishlist called.""" + db, ws = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('Artist')] + 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]) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['matched'] == 1 + assert len(ws.added) == 1 + add_args = ws.added[0] + assert add_args['source_type'] == 'quality_scanner' + assert add_args['source_context']['original_file_path'] == '/x.mp3' + + +def test_no_match_no_wishlist_add(mock_db_and_wishlist): + """No match found → no wishlist add, matched stays 0.""" + db, ws = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('A')] + 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=[]) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['matched'] == 0 + assert ws.added == [] + + +# --------------------------------------------------------------------------- +# Stop request gate +# --------------------------------------------------------------------------- + +def test_stop_request_halts_loop(mock_db_and_wishlist): + """Setting state['status'] != 'running' mid-loop halts processing.""" + db, _ = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('A')] + db._tracks = [_track_row(track_id=1), _track_row(track_id=2)] + state = {} + deps = _build_deps(state=state, quality_tier_result=('lossless', 1)) + + # Override get_quality_tier_from_extension to set stop after first track + call_count = [0] + + def stop_after_first(fp): + call_count[0] += 1 + if call_count[0] == 1: + # Set status to non-running BEFORE second track iter checks + with deps.quality_scanner_lock: + state['status'] = 'stopping' + return ('lossless', 1) + + deps.get_quality_tier_from_extension = stop_after_first + + qs.run_quality_scanner('watchlist', 1, deps) + + # Only first track processed + assert state['quality_met'] == 1 + + +# --------------------------------------------------------------------------- +# Completion +# --------------------------------------------------------------------------- + +def test_completion_marks_finished(mock_db_and_wishlist): + """All tracks processed → status='finished', progress=100.""" + db, _ = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('A')] + db._tracks = [_track_row()] + state = {} + deps = _build_deps(state=state) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert state['status'] == 'finished' + assert state['progress'] == 100 + + +def test_automation_event_emitted(mock_db_and_wishlist): + """Successful completion emits 'quality_scan_completed' on automation engine.""" + db, _ = mock_db_and_wishlist + db._watchlist_artists = [_WatchlistArtist('A')] + db._tracks = [_track_row()] + automation = _FakeAutomationEngine() + state = {} + deps = _build_deps(state=state, automation=automation) + + qs.run_quality_scanner('watchlist', 1, deps) + + assert any(name == 'quality_scan_completed' for name, _ in automation.events) + + +# --------------------------------------------------------------------------- +# All-library scope +# --------------------------------------------------------------------------- + +def test_scope_all_loads_all_tracks(mock_db_and_wishlist): + """scope != 'watchlist' loads all tracks (no watchlist filter).""" + db, _ = mock_db_and_wishlist + db._tracks = [_track_row(track_id=1), _track_row(track_id=2)] + state = {} + deps = _build_deps(state=state) + + qs.run_quality_scanner('all', 1, deps) + + assert state['total'] == 2 diff --git a/web_server.py b/web_server.py index a1892d90..f2d93f2f 100644 --- a/web_server.py +++ b/web_server.py @@ -20146,334 +20146,28 @@ def _get_quality_tier_from_extension(file_path): return ('unknown', 999) +# Quality scanner worker logic lives in core/discovery/quality_scanner.py. +from core.discovery import quality_scanner as _discovery_quality_scanner + + +def _build_quality_scanner_deps(): + """Build the QualityScannerDeps bundle from web_server.py globals on each call.""" + return _discovery_quality_scanner.QualityScannerDeps( + 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, + add_activity_item=add_activity_item, + ) + + def _run_quality_scanner(scope='watchlist', profile_id=1): - """Main quality scanner worker function""" - from core.wishlist_service import get_wishlist_service - from database.music_database import MusicDatabase - - try: - with quality_scanner_lock: - quality_scanner_state["status"] = "running" - quality_scanner_state["phase"] = "Initializing scan..." - quality_scanner_state["progress"] = 0 - quality_scanner_state["processed"] = 0 - quality_scanner_state["total"] = 0 - quality_scanner_state["quality_met"] = 0 - quality_scanner_state["low_quality"] = 0 - quality_scanner_state["matched"] = 0 - quality_scanner_state["results"] = [] - quality_scanner_state["error_message"] = "" - - logger.info(f"[Quality Scanner] Starting scan with scope: {scope}") - - # Get database instance - db = MusicDatabase() - - # Get quality profile to determine preferred quality - quality_profile = db.get_quality_profile() - preferred_qualities = quality_profile.get('qualities', {}) - - # Determine minimum acceptable tier based on enabled qualities - min_acceptable_tier = 999 - for quality_name, quality_config in preferred_qualities.items(): - if quality_config.get('enabled', False): - # Map quality profile names to tier names - tier_map = { - 'flac': 'lossless', - 'mp3_320': 'low_lossy', - 'mp3_256': 'low_lossy', - 'mp3_192': 'low_lossy' - } - tier_name = tier_map.get(quality_name) - if tier_name: - tier_num = QUALITY_TIERS[tier_name]['tier'] - min_acceptable_tier = min(min_acceptable_tier, tier_num) - - logger.info(f"[Quality Scanner] Minimum acceptable tier: {min_acceptable_tier}") - - # Get tracks to scan based on scope - with quality_scanner_lock: - quality_scanner_state["phase"] = "Loading tracks from database..." - - if scope == 'watchlist': - # Get watchlist artists - watchlist_artists = db.get_watchlist_artists(profile_id=profile_id) - if not watchlist_artists: - with quality_scanner_lock: - quality_scanner_state["status"] = "finished" - quality_scanner_state["phase"] = "No watchlist artists found" - quality_scanner_state["error_message"] = "Please add artists to watchlist first" - logger.warning("[Quality Scanner] No watchlist artists found") - return - - # Get artist names from watchlist - artist_names = [artist.artist_name for artist in watchlist_artists] - logger.info(f"[Quality Scanner] Scanning {len(artist_names)} watchlist artists") - - # Get all tracks for these artists by name - conn = db._get_connection() - placeholders = ','.join(['?' for _ in artist_names]) - tracks_to_scan = conn.execute( - f"SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title " - f"FROM tracks t " - f"JOIN artists a ON t.artist_id = a.id " - f"JOIN albums al ON t.album_id = al.id " - f"WHERE a.name IN ({placeholders}) AND t.file_path IS NOT NULL", - artist_names - ).fetchall() - conn.close() - else: - # Scan all library tracks - with quality_scanner_lock: - quality_scanner_state["phase"] = "Loading all library tracks..." - - conn = db._get_connection() - tracks_to_scan = conn.execute( - "SELECT t.id, t.title, t.artist_id, t.album_id, t.file_path, t.bitrate, a.name as artist_name, al.title as album_title " - "FROM tracks t " - "JOIN artists a ON t.artist_id = a.id " - "JOIN albums al ON t.album_id = al.id " - "WHERE t.file_path IS NOT NULL" - ).fetchall() - conn.close() - - total_tracks = len(tracks_to_scan) - logger.info(f"[Quality Scanner] Found {total_tracks} tracks to scan") - - with quality_scanner_lock: - quality_scanner_state["total"] = total_tracks - quality_scanner_state["phase"] = f"Scanning {total_tracks} tracks..." - - # Use the module-level spotify_client (already authenticated with cached token) - if not spotify_client or not spotify_client.is_spotify_authenticated(): - with quality_scanner_lock: - quality_scanner_state["status"] = "error" - quality_scanner_state["phase"] = "Spotify not authenticated" - quality_scanner_state["error_message"] = "Please authenticate with Spotify first" - logger.info("[Quality Scanner] Spotify not authenticated") - return - - wishlist_service = get_wishlist_service() - - # Scan each track - for idx, track_row in enumerate(tracks_to_scan, 1): - # Check for stop request - if quality_scanner_state.get('status') != 'running': - logger.info(f"[Quality Scanner] Stop requested, halting at track {idx}/{total_tracks}") - break - - try: - track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title = track_row - - # Check quality tier - tier_name, tier_num = _get_quality_tier_from_extension(file_path) - - # Update progress - with quality_scanner_lock: - quality_scanner_state["processed"] = idx - quality_scanner_state["progress"] = (idx / total_tracks) * 100 - quality_scanner_state["phase"] = f"Scanning: {artist_name} - {title}" - - # Check if meets quality standards - if tier_num <= min_acceptable_tier: - # Quality met - with quality_scanner_lock: - quality_scanner_state["quality_met"] += 1 - continue - - # Low quality track found - with quality_scanner_lock: - quality_scanner_state["low_quality"] += 1 - - logger.info(f"[Quality Scanner] Low quality: {artist_name} - {title} ({tier_name}, {file_path})") - - # Attempt to match to Spotify using matching_engine - matched = False - matched_track_data = None - - try: - # Generate search queries using matching engine - temp_track = type('TempTrack', (), { - 'name': title, - 'artists': [artist_name], - 'album': album_title - })() - - search_queries = matching_engine.generate_download_queries(temp_track) - logger.info(f"[Quality Scanner] Generated {len(search_queries)} search queries for {artist_name} - {title}") - - # Find best match using confidence scoring - best_match = None - best_confidence = 0.0 - min_confidence = 0.7 # Match existing standard - - for _query_idx, search_query in enumerate(search_queries): - try: - spotify_matches = 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 = matching_engine.similarity_score( - matching_engine.normalize_string(artist_name), - matching_engine.normalize_string(result_artist) - ) - artist_confidence = max(artist_confidence, artist_sim) - - # Calculate title confidence - title_confidence = matching_engine.similarity_score( - matching_engine.normalize_string(title), - 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}") - 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 - - except Exception as e: - logger.debug(f"[Quality Scanner] Error searching with query '{search_query}': {e}") - continue - - # 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})") - - # 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 {} - } - - # Add to wishlist - source_context = { - 'quality_scanner': True, - 'original_file_path': file_path, - 'original_format': tier_name, - 'original_bitrate': bitrate, - 'match_confidence': best_confidence, - 'scan_date': datetime.now().isoformat() - } - - success = wishlist_service.add_spotify_track_to_wishlist( - spotify_track_data=matched_track_data, - failure_reason=f"Low quality - {tier_name.replace('_', ' ').title()} format", - source_type='quality_scanner', - source_context=source_context, - profile_id=profile_id - ) - - if success: - with quality_scanner_lock: - quality_scanner_state["matched"] += 1 - logger.info(f"[Quality Scanner] Matched and added to wishlist: {artist_name} - {title}") - 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})") - - 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 - } - - with quality_scanner_lock: - quality_scanner_state["results"].append(result_entry) - - if not matched: - logger.warning(f"[Quality Scanner] No Spotify match found for: {artist_name} - {title}") - - except Exception as track_error: - logger.error(f"[Quality Scanner] Error processing track: {track_error}") - continue - - # Scan complete (don't overwrite if already stopped by user) - with quality_scanner_lock: - was_stopped = quality_scanner_state["status"] != "running" - quality_scanner_state["status"] = "finished" - quality_scanner_state["progress"] = 100 - if not was_stopped: - quality_scanner_state["phase"] = "Scan complete" - - logger.info(f"[Quality Scanner] Scan {'stopped' if was_stopped else 'complete'}: {quality_scanner_state['processed']} processed, " - f"{quality_scanner_state['low_quality']} low quality, {quality_scanner_state['matched']} matched to Spotify") - - # Add activity - add_activity_item("", "Quality Scan Complete", - f"{quality_scanner_state['matched']} tracks added to wishlist", "Now") - - try: - if automation_engine: - automation_engine.emit('quality_scan_completed', { - 'quality_met': str(quality_scanner_state.get('quality_met', 0)), - 'low_quality': str(quality_scanner_state.get('low_quality', 0)), - 'total_scanned': str(quality_scanner_state.get('processed', 0)), - }) - except Exception: - pass - - except Exception as e: - logger.error(f"[Quality Scanner] Critical error: {e}") - import traceback - traceback.print_exc() - - with quality_scanner_lock: - quality_scanner_state["status"] = "error" - quality_scanner_state["error_message"] = str(e) - quality_scanner_state["phase"] = f"Error: {str(e)}" + return _discovery_quality_scanner.run_quality_scanner( + scope, profile_id, _build_quality_scanner_deps() + ) def _run_duplicate_cleaner():