diff --git a/core/automation/blocks.py b/core/automation/blocks.py index 2697af26..ecd88cd4 100644 --- a/core/automation/blocks.py +++ b/core/automation/blocks.py @@ -171,12 +171,7 @@ ACTIONS: list[dict] = [ {"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass", "description": "Refresh discovery pool with new tracks", "available": True}, {"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart", - "description": "Scan for low-quality audio files", "available": True, - "config_fields": [ - {"key": "scope", "type": "select", "label": "Scope", - "options": [{"value": "watchlist", "label": "Watchlist Artists"}, {"value": "library", "label": "Full Library"}], - "default": "watchlist"} - ]}, + "description": "Run the Quality Upgrade Finder (scope is set in Library Maintenance)", "available": True}, {"type": "backup_database", "label": "Backup Database", "icon": "save", "description": "Create timestamped database backup", "available": True}, {"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music", diff --git a/core/automation/deps.py b/core/automation/deps.py index 09c52a17..fb45bb42 100644 --- a/core/automation/deps.py +++ b/core/automation/deps.py @@ -123,10 +123,10 @@ class AutomationDeps: duplicate_cleaner_lock: Any duplicate_cleaner_executor: Any run_duplicate_cleaner: Callable[..., Any] - get_quality_scanner_state: Callable[[], dict] - quality_scanner_lock: Any - quality_scanner_executor: Any - run_quality_scanner: Callable[..., Any] + # Triggers a "Run Now" of a library-maintenance repair job by id (e.g. + # 'quality_upgrade'). Returns truthy if the job was queued. Replaces the old + # standalone quality-scanner executor/state (the scanner is now a repair job). + run_repair_job_now: Callable[[str], Any] # --- Download orchestrator + queue accessors --- download_orchestrator: Any diff --git a/core/automation/handlers/quality_scanner.py b/core/automation/handlers/quality_scanner.py index 69ec3f02..13f71ed9 100644 --- a/core/automation/handlers/quality_scanner.py +++ b/core/automation/handlers/quality_scanner.py @@ -1,83 +1,35 @@ """Automation handler: ``start_quality_scan`` action. -Lifted from ``web_server._register_automation_handlers`` (the -``_auto_start_quality_scan`` closure). Submits the quality scanner -to its executor with the configured scope (default: ``watchlist``) -then polls the shared state dict. +The quality scanner was redesigned from an auto-acting tool into the +``quality_upgrade`` library-maintenance repair job (findings-based, reviewed +before anything is wishlisted). This action now simply triggers a "Run Now" of +that job; its progress and findings surface in Library Maintenance. The action +name is kept so existing automation rules keep working. """ from __future__ import annotations -import time from typing import Any, Dict from core.automation.deps import AutomationDeps -_TIMEOUT_SECONDS = 7200 # 2 hours -_POLL_INTERVAL_SECONDS = 3 -_INITIAL_DELAY_SECONDS = 1 - - def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]: automation_id = config.get('_automation_id') - state = deps.get_quality_scanner_state() - if state.get('status') == 'running': - return {'status': 'skipped', 'reason': 'Quality scan already running'} - scope = config.get('scope', 'watchlist') - # Pre-set status before submit so the polling loop doesn't see a - # stale 'finished' from a previous run. - with deps.quality_scanner_lock: - state['status'] = 'running' - deps.quality_scanner_executor.submit(deps.run_quality_scanner, scope, deps.get_current_profile_id()) - deps.update_progress( - automation_id, log_line=f'Quality scan started (scope: {scope})', log_type='info', - ) - - # Monitor progress (max 2 hours). - time.sleep(_INITIAL_DELAY_SECONDS) - poll_start = time.time() - while time.time() - poll_start < _TIMEOUT_SECONDS: - time.sleep(_POLL_INTERVAL_SECONDS) - current_status = state.get('status', 'idle') - if current_status not in ('running',): - break + triggered = deps.run_repair_job_now('quality_upgrade') + if not triggered: deps.update_progress( - automation_id, - phase=state.get('phase', 'Scanning...'), - progress=state.get('progress', 0), - processed=state.get('processed', 0), - total=state.get('total', 0), - ) - else: - deps.update_progress( - automation_id, status='error', - phase='Timed out', log_line='Quality scan timed out after 2 hours', + automation_id, status='error', phase='Unavailable', + log_line='Quality Upgrade job could not be triggered (library worker unavailable)', log_type='error', ) - return {'status': 'error', 'reason': 'Timed out', '_manages_own_progress': True} + return {'status': 'error', 'reason': 'library worker unavailable', + '_manages_own_progress': True} - final_status = state.get('status', 'idle') - if final_status == 'error': - err = state.get('error_message', 'Unknown error') - deps.update_progress( - automation_id, status='error', progress=100, - phase='Error', log_line=err, log_type='error', - ) - return {'status': 'error', 'reason': err, '_manages_own_progress': True} - - issues = state.get('low_quality', 0) deps.update_progress( - automation_id, status='finished', progress=100, - phase='Complete', - log_line=f'Quality scan complete — {issues} issues found', + automation_id, status='finished', progress=100, phase='Triggered', + log_line='Quality Upgrade scan queued — findings appear in Library Maintenance', log_type='success', ) - return { - 'status': 'completed', 'scope': scope, '_manages_own_progress': True, - 'tracks_scanned': state.get('processed', 0), - 'quality_met': state.get('quality_met', 0), - 'low_quality': issues, - 'matched': state.get('matched', 0), - } + return {'status': 'completed', 'triggered': True, '_manages_own_progress': True} diff --git a/core/automation/handlers/registration.py b/core/automation/handlers/registration.py index 94f23db6..3afbe4df 100644 --- a/core/automation/handlers/registration.py +++ b/core/automation/handlers/registration.py @@ -136,7 +136,7 @@ def register_all(deps: AutomationDeps) -> None: engine.register_action_handler( 'start_quality_scan', lambda config: auto_start_quality_scan(config, deps), - lambda: deps.get_quality_scanner_state().get('status') == 'running', + lambda: False, # repair worker dedupes Run-Now requests itself ) engine.register_action_handler( 'backup_database', diff --git a/core/discovery/quality_scanner.py b/core/discovery/quality_scanner.py index 27457ca7..7c689a38 100644 --- a/core/discovery/quality_scanner.py +++ b/core/discovery/quality_scanner.py @@ -1,40 +1,23 @@ -"""Background worker for the library quality scanner. +"""Shared metadata match + result-normalization helpers for quality matching. -`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 provider matches to the wishlist: +These were the matching guts of the old auto-acting quality-scanner worker (now +removed — quality scanning is the ``quality_upgrade`` library-maintenance repair +job in ``core/repair_jobs/quality_upgrade.py``). They're kept here as a single +source of truth and imported by that job: -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 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. +- ``_search_tracks_for_source`` — query one metadata source's ``search_tracks``. +- ``_normalize_track_match`` / ``_normalize_track_album`` / ``_normalize_track_artists`` + — turn a provider track into the wishlist-ready dict (typed Album converters + with legacy duck-typed fallback). +- ``_track_name`` / ``_track_artist_names`` / ``_extract_lookup_value`` — accessors. """ from __future__ import annotations import logging -import time -from dataclasses import dataclass -from datetime import datetime from typing import Any, Callable, Dict, Optional -from core.metadata.registry import get_client_for_source, get_primary_source, get_source_priority +from core.metadata.registry import get_client_for_source from core.metadata.types import Album from core.wishlist.payloads import ensure_wishlist_track_format @@ -56,16 +39,6 @@ _TYPED_ALBUM_CONVERTERS: Dict[str, Callable[[Dict[str, Any]], Album]] = { } -@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 - 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: @@ -300,363 +273,3 @@ def _search_tracks_for_source(source: str, query: str, limit: int = 5, client: A 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 - 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..." - - 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"] = "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): - # 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 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 - 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: - for source in source_priority: - client = get_client_for_source(source) - if not client or not hasattr(client, 'search_tracks'): - continue - - 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 - 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 normalized track data for wishlist - matched_track_data = _normalize_track_match(best_match, final_source) - - # 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 = 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, - 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 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, - '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) - - 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}") - 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 metadata providers") - - # 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 as e: - logger.debug("emit quality_scan_completed failed: %s", e) - - 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/automation/test_handler_registration.py b/tests/automation/test_handler_registration.py index 357ebc06..7b23c67a 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -142,10 +142,7 @@ def _build_deps(engine, scan_mgr=None) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), @@ -360,7 +357,6 @@ class TestHandlerInvocation: **{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()}, 'get_db_update_state': lambda: running_state, 'get_duplicate_cleaner_state': lambda: running_state, - 'get_quality_scanner_state': lambda: running_state, 'get_download_batches': lambda: active_batches, # forces clean_completed_downloads to skip 'get_database': lambda: _StubDB(), 'get_app': lambda: _StubApp(), diff --git a/tests/automation/test_handlers_maintenance.py b/tests/automation/test_handlers_maintenance.py index e9c62a05..36ca357b 100644 --- a/tests/automation/test_handlers_maintenance.py +++ b/tests/automation/test_handlers_maintenance.py @@ -89,10 +89,7 @@ def _build_deps(**overrides) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), @@ -185,11 +182,18 @@ class TestDuplicateCleaner: class TestQualityScanner: - def test_already_running_returns_skipped(self): - state = {'status': 'running'} - deps = _build_deps(get_quality_scanner_state=lambda: state) + def test_triggers_quality_upgrade_repair_job(self): + triggered = [] + deps = _build_deps(run_repair_job_now=lambda job_id: triggered.append(job_id) or True) result = auto_start_quality_scan({}, deps) - assert result == {'status': 'skipped', 'reason': 'Quality scan already running'} + assert triggered == ['quality_upgrade'] + assert result['status'] == 'completed' + assert result['triggered'] is True + + def test_error_when_worker_unavailable(self): + deps = _build_deps(run_repair_job_now=lambda job_id: None) + result = auto_start_quality_scan({}, deps) + assert result['status'] == 'error' # ─── clear_quarantine ──────────────────────────────────────────────── diff --git a/tests/automation/test_handlers_personalized_pipeline.py b/tests/automation/test_handlers_personalized_pipeline.py index af91bb03..019a2250 100644 --- a/tests/automation/test_handlers_personalized_pipeline.py +++ b/tests/automation/test_handlers_personalized_pipeline.py @@ -63,10 +63,7 @@ def _build_deps(**overrides) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index e13179e3..a905c1e7 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -125,10 +125,7 @@ def _build_deps(**overrides) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/automation/test_handlers_simple.py b/tests/automation/test_handlers_simple.py index 9320b96d..f34d3004 100644 --- a/tests/automation/test_handlers_simple.py +++ b/tests/automation/test_handlers_simple.py @@ -76,10 +76,7 @@ def _build_deps(**overrides: Any) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/automation/test_playlist_pipeline_folder_mode.py b/tests/automation/test_playlist_pipeline_folder_mode.py index 9c412fce..e727ec95 100644 --- a/tests/automation/test_playlist_pipeline_folder_mode.py +++ b/tests/automation/test_playlist_pipeline_folder_mode.py @@ -41,10 +41,7 @@ def _minimal_deps(**overrides): duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/automation/test_progress_callbacks.py b/tests/automation/test_progress_callbacks.py index 16728255..cdceac05 100644 --- a/tests/automation/test_progress_callbacks.py +++ b/tests/automation/test_progress_callbacks.py @@ -62,10 +62,7 @@ def _build_deps(**overrides) -> AutomationDeps: duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_executor=None, run_duplicate_cleaner=lambda: None, - get_quality_scanner_state=lambda: {}, - quality_scanner_lock=threading.Lock(), - quality_scanner_executor=None, - run_quality_scanner=lambda *a, **k: None, + run_repair_job_now=lambda *a, **k: True, download_orchestrator=None, run_async=lambda coro: None, tasks_lock=threading.Lock(), diff --git a/tests/conftest.py b/tests/conftest.py index 75c3de68..288f439b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -113,12 +113,6 @@ _DEFAULT_STREAM_STATE = { "error_message": None, } -_DEFAULT_QUALITY_SCANNER_STATE = { - "status": "running", "phase": "Scanning...", "progress": 35, - "processed": 35, "total": 100, "quality_met": 30, - "low_quality": 5, "matched": 2, "error_message": "", "results": [], -} - _DEFAULT_DUPLICATE_CLEANER_STATE = { "status": "running", "phase": "Scanning...", "progress": 50, "files_scanned": 500, "total_files": 1000, "duplicates_found": 10, @@ -257,7 +251,6 @@ enrichment_status = copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS) # Phase 4: Tool progress state stream_state = copy.deepcopy(_DEFAULT_STREAM_STATE) -quality_scanner_state = copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE) duplicate_cleaner_state = copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE) retag_state = copy.deepcopy(_DEFAULT_RETAG_STATE) db_update_state = copy.deepcopy(_DEFAULT_DB_UPDATE_STATE) @@ -350,13 +343,12 @@ ENRICHMENT_ENDPOINTS = { # Phase 4 helpers TOOL_NAMES = [ - 'stream', 'quality-scanner', 'duplicate-cleaner', + 'stream', 'duplicate-cleaner', 'retag', 'db-update', 'metadata', 'logs', ] TOOL_ENDPOINTS = { 'stream': '/api/stream/status', - 'quality-scanner': '/api/quality-scanner/status', 'duplicate-cleaner': '/api/duplicate-cleaner/status', 'retag': '/api/retag/status', 'db-update': '/api/database/update/status', @@ -374,10 +366,6 @@ def _build_stream_status(): } -def _build_quality_scanner_status(): - return dict(quality_scanner_state) - - def _build_duplicate_cleaner_status(): state_copy = duplicate_cleaner_state.copy() state_copy["space_freed_mb"] = duplicate_cleaner_state["space_freed"] / (1024 * 1024) @@ -419,7 +407,6 @@ def _build_tool_status(tool_name): """Dispatcher that returns the correct status payload for any tool.""" builders = { 'stream': _build_stream_status, - 'quality-scanner': _build_quality_scanner_status, 'duplicate-cleaner': _build_duplicate_cleaner_status, 'retag': _build_retag_status, 'db-update': _build_db_update_status, @@ -635,10 +622,6 @@ def test_app(): def stream_status_endpoint(): return jsonify(_build_stream_status()) - @app.route('/api/quality-scanner/status') - def quality_scanner_status_endpoint(): - return jsonify(_build_quality_scanner_status()) - @app.route('/api/duplicate-cleaner/status') def duplicate_cleaner_status_endpoint(): return jsonify(_build_duplicate_cleaner_status()) @@ -961,7 +944,6 @@ def shared_state(): 'enrichment_endpoints': ENRICHMENT_ENDPOINTS, # Phase 4 state 'stream_state': stream_state, - 'quality_scanner_state': quality_scanner_state, 'duplicate_cleaner_state': duplicate_cleaner_state, 'retag_state': retag_state, 'db_update_state': db_update_state, @@ -969,7 +951,6 @@ def shared_state(): 'logs_activities': logs_activities, 'build_tool_status': _build_tool_status, 'build_stream_status': _build_stream_status, - 'build_quality_scanner_status': _build_quality_scanner_status, 'build_duplicate_cleaner_status': _build_duplicate_cleaner_status, 'build_retag_status': _build_retag_status, 'build_db_update_status': _build_db_update_status, @@ -1019,8 +1000,6 @@ def reset_state(): # Phase 4 resets stream_state.clear() stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) - quality_scanner_state.clear() - quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE)) duplicate_cleaner_state.clear() duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) retag_state.clear() @@ -1061,8 +1040,6 @@ def reset_state(): enrichment_status.update(copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS)) stream_state.clear() stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) - quality_scanner_state.clear() - quality_scanner_state.update(copy.deepcopy(_DEFAULT_QUALITY_SCANNER_STATE)) duplicate_cleaner_state.clear() duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) retag_state.clear() diff --git a/tests/discovery/test_discovery_quality_scanner.py b/tests/discovery/test_discovery_quality_scanner.py deleted file mode 100644 index f3395ad4..00000000 --- a/tests/discovery/test_discovery_quality_scanner.py +++ /dev/null @@ -1,492 +0,0 @@ -"""Tests for core/discovery/quality_scanner.py — library quality scanner.""" - -from __future__ import annotations - -import threading -from dataclasses import dataclass - -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 _FakeMetadataClient: - def __init__(self, results=None): - self._results = results if results is not None else [] - self.search_calls = [] - - 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}"] - - 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 _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 = [] - - 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, - 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}}, - 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'] - - -# --------------------------------------------------------------------------- -# Provider availability gate -# --------------------------------------------------------------------------- - -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, source_clients={}, quality_tier_result=('low_lossy', 4)) - - qs.run_quality_scanner('watchlist', 1, deps) - - assert state['status'] == 'error' - assert 'metadata provider' 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 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']) - 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 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' - - -# --------------------------------------------------------------------------- -# 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), - source_clients={'spotify': _FakeMetadataClient(results=[match])}, - primary_source='spotify', - ) - - 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_match_preserves_album_and_artist_images(mock_db_and_wishlist): - """Image metadata from the provider payload should survive the wishlist handoff.""" - 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 = { - 'id': 'sp-1', - 'name': 'Track', - 'artists': [{'name': 'Artist', 'image_url': 'https://example.test/artist.jpg'}], - 'album': 'Album', - 'image_url': 'https://example.test/cover.jpg', - 'duration_ms': 200000, - 'popularity': 50, - 'external_urls': {}, - 'album_type': 'album', - 'release_date': '2024-01-01', - } - 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) - - assert state['matched'] == 1 - assert len(ws.added) == 1 - add_args = ws.added[0] - assert add_args['track_data']['image_url'] == 'https://example.test/cover.jpg' - assert add_args['track_data']['album']['image_url'] == 'https://example.test/cover.jpg' - assert add_args['track_data']['album']['images'] == [{'url': 'https://example.test/cover.jpg'}] - assert add_args['track_data']['artists'][0]['image_url'] == 'https://example.test/artist.jpg' - - -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), - source_clients={'spotify': _FakeMetadataClient(results=[])}, - primary_source='spotify', - ) - - 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/tests/test_tool_progress_events.py b/tests/test_tool_progress_events.py index d23dc765..d8424dc6 100644 --- a/tests/test_tool_progress_events.py +++ b/tests/test_tool_progress_events.py @@ -13,16 +13,15 @@ conftest is a different module instance. Use the ``shared_state`` fixture instea import pytest -# All 7 tool progress pollers +# Tool progress pollers TOOLS = [ - 'stream', 'quality-scanner', 'duplicate-cleaner', + 'stream', 'duplicate-cleaner', 'retag', 'db-update', 'metadata', 'logs', ] # Endpoint URLs keyed by tool name ENDPOINTS = { 'stream': '/api/stream/status', - 'quality-scanner': '/api/quality-scanner/status', 'duplicate-cleaner': '/api/duplicate-cleaner/status', 'retag': '/api/retag/status', 'db-update': '/api/database/update/status', @@ -63,27 +62,6 @@ class TestToolDataShape: assert 'error_message' in data assert isinstance(data['progress'], (int, float)) - def test_quality_scanner_shape(self, test_app, shared_state): - """Quality scanner has status, phase, progress, processed, total, quality_met.""" - app, socketio = test_app - client = socketio.test_client(app) - build = shared_state['build_quality_scanner_status'] - - socketio.emit('tool:quality-scanner', build()) - received = client.get_received() - events = [e for e in received if e['name'] == 'tool:quality-scanner'] - assert len(events) >= 1 - data = events[0]['args'][0] - - assert 'status' in data - assert 'phase' in data - assert 'progress' in data - assert 'processed' in data - assert 'total' in data - assert 'quality_met' in data - assert 'low_quality' in data - assert 'matched' in data - def test_duplicate_cleaner_shape(self, test_app, shared_state): """Duplicate cleaner has status, phase, progress, space_freed_mb.""" app, socketio = test_app @@ -255,11 +233,11 @@ class TestToolBackwardCompat: client2 = socketio.test_client(app) build = shared_state['build_tool_status'] - socketio.emit('tool:quality-scanner', build('quality-scanner')) + socketio.emit('tool:duplicate-cleaner', build('duplicate-cleaner')) for client in [client1, client2]: received = client.get_received() - events = [e for e in received if e['name'] == 'tool:quality-scanner'] + events = [e for e in received if e['name'] == 'tool:duplicate-cleaner'] assert len(events) >= 1 client1.disconnect() @@ -270,17 +248,15 @@ class TestToolBackwardCompat: app, socketio = test_app client = socketio.test_client(app) build = shared_state['build_tool_status'] - qs = shared_state['quality_scanner_state'] + dc = shared_state['duplicate_cleaner_state'] # Mutate state - qs['status'] = 'finished' - qs['progress'] = 100 - qs['processed'] = 100 + dc['status'] = 'finished' + dc['progress'] = 100 - socketio.emit('tool:quality-scanner', build('quality-scanner')) + socketio.emit('tool:duplicate-cleaner', build('duplicate-cleaner')) received = client.get_received() - events = [e for e in received if e['name'] == 'tool:quality-scanner'] + events = [e for e in received if e['name'] == 'tool:duplicate-cleaner'] data = events[-1]['args'][0] assert data['status'] == 'finished' assert data['progress'] == 100 - assert data['processed'] == 100 diff --git a/web_server.py b/web_server.py index 300c3a36..0196fe9b 100644 --- a/web_server.py +++ b/web_server.py @@ -990,21 +990,8 @@ def _set_db_update_automation_id(value): global _db_update_automation_id _db_update_automation_id = value -# Quality Scanner state -quality_scanner_state = { - "status": "idle", # idle, running, finished, error - "phase": "Ready to scan", - "progress": 0, - "processed": 0, - "total": 0, - "quality_met": 0, - "low_quality": 0, - "matched": 0, - "error_message": "", - "results": [], # List of low quality tracks with match status -} -quality_scanner_lock = threading.Lock() -quality_scanner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="QualityScanner") +# Quality scanning is now the 'quality_upgrade' library-maintenance repair job +# (core/repair_jobs/quality_upgrade.py) — no standalone state/executor here. # Duplicate Cleaner state duplicate_cleaner_state = { @@ -1253,10 +1240,7 @@ def _register_automation_handlers(): duplicate_cleaner_lock=duplicate_cleaner_lock, duplicate_cleaner_executor=duplicate_cleaner_executor, run_duplicate_cleaner=_run_duplicate_cleaner, - get_quality_scanner_state=lambda: quality_scanner_state, - quality_scanner_lock=quality_scanner_lock, - quality_scanner_executor=quality_scanner_executor, - run_quality_scanner=_run_quality_scanner, + run_repair_job_now=lambda job_id: repair_worker.run_job_now(job_id) if repair_worker else None, download_orchestrator=download_orchestrator, run_async=run_async, tasks_lock=tasks_lock, @@ -1859,7 +1843,6 @@ def _shutdown_runtime_components(): for executor, name in [ (stream_executor, "stream executor"), (db_update_executor, "db update executor"), - (quality_scanner_executor, "quality scanner executor"), (duplicate_cleaner_executor, "duplicate cleaner executor"), (sync_executor, "sync executor"), (missing_download_executor, "missing download executor"), @@ -17639,28 +17622,7 @@ 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, - 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): - return _discovery_quality_scanner.run_quality_scanner( - scope, profile_id, _build_quality_scanner_deps() - ) - +# (Quality scanning moved to the 'quality_upgrade' library-maintenance repair job.) from core.library.duplicate_cleaner import ( _run_duplicate_cleaner, @@ -17673,55 +17635,6 @@ _init_duplicate_cleaner( engine=automation_engine, ) -@app.route('/api/quality-scanner/start', methods=['POST']) -def start_quality_scan(): - """Start the quality scanner""" - with quality_scanner_lock: - if quality_scanner_state["status"] == "running": - return jsonify({"success": False, "error": "A scan is already in progress"}), 409 - - data = request.get_json() or {} - scope = data.get('scope', 'watchlist') # 'watchlist' or 'all' - - logger.info(f"[Quality Scanner API] Starting scan with scope: {scope}") - - # Reset state - quality_scanner_state["status"] = "running" - quality_scanner_state["phase"] = "Initializing..." - 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"] = "" - - # Submit worker (capture profile_id before thread) - scan_profile_id = get_current_profile_id() - quality_scanner_executor.submit(_run_quality_scanner, scope, scan_profile_id) - - add_activity_item("", "Quality Scan Started", f"Scanning {scope} tracks", "Now") - - return jsonify({"success": True, "message": "Quality scan started"}) - -@app.route('/api/quality-scanner/status', methods=['GET']) -def get_quality_scanner_status(): - """Get current quality scanner status""" - with quality_scanner_lock: - return jsonify(quality_scanner_state) - -@app.route('/api/quality-scanner/stop', methods=['POST']) -def stop_quality_scan(): - """Stop the quality scanner (sets a stop flag)""" - with quality_scanner_lock: - if quality_scanner_state["status"] == "running": - quality_scanner_state["status"] = "finished" - quality_scanner_state["phase"] = "Scan stopped by user" - return jsonify({"success": True, "message": "Stop request sent"}) - else: - return jsonify({"success": False, "error": "No scan is currently running"}), 404 - @app.route('/api/duplicate-cleaner/start', methods=['POST']) def start_duplicate_cleaner(): """Start the duplicate cleaner""" @@ -37341,12 +37254,6 @@ def _emit_tool_progress_loop(): # (which skipped HTTP polling while the socket was up) never learned # its stream was ready. Each client polls /api/stream/status instead, # which resolves its own session from the cookie. - # Quality Scanner - try: - with quality_scanner_lock: - socketio.emit('tool:quality-scanner', dict(quality_scanner_state)) - except Exception as e: - logger.debug(f"Error emitting quality scanner status: {e}") # Duplicate Cleaner (add computed space_freed_mb) try: with duplicate_cleaner_lock: diff --git a/webui/index.html b/webui/index.html index a93dd265..af40adfa 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6831,49 +6831,6 @@ -
-
-

Quality Scanner

- -
-

Scan library for tracks below quality preferences

-
-
- Processed: - 0 -
-
- Quality Met: - 0 -
-
- Low Quality: - 0 -
-
- Matched: - 0 -
-
-
- - -
-
-

Ready to scan

-
-
-
-
-

0 / 0 tracks scanned - (0.0%)

-
-
-

Import IDs from File Tags

diff --git a/webui/static/core.js b/webui/static/core.js index ca175627..a6b0f0f9 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -29,7 +29,6 @@ let isSortReversed = false; let searchAbortController = null; let dbStatsInterval = null; let dbUpdateStatusInterval = null; -let qualityScannerStatusInterval = null; let duplicateCleanerStatusInterval = null; let wishlistCountInterval = null; let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal @@ -487,7 +486,6 @@ function initializeWebSocket() { // 'tool:stream' is intentionally NOT wired: stream state is per-listener // (session cookie), so the global broadcast could only carry the DEFAULT // session's eternal "stopped" — the player polls /api/stream/status instead. - socket.on('tool:quality-scanner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateQualityScanProgressFromData(data); }); socket.on('tool:duplicate-cleaner', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDuplicateCleanProgressFromData(data); }); socket.on('tool:db-update', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDbProgressFromData(data); }); socket.on('tool:metadata', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateMetadataStatusFromData(data); }); diff --git a/webui/static/helper.js b/webui/static/helper.js index 60bba85a..b1168b2d 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -328,16 +328,6 @@ const HELPER_CONTENT = { ], docsId: 'dashboard' }, - '#quality-scanner-card': { - title: 'Quality Scanner', - description: 'Analyzes audio files for quality integrity. Calculates bitrate density to detect transcodes (e.g., an MP3 re-encoded as FLAC). Scope options: Full Library, New Only, or Single Artist.', - tips: [ - '"Quality Met" = file quality matches its format claims', - '"Low Quality" = suspicious file flagged for review', - 'Matched count shows tracks with verified metadata' - ], - docsId: 'dashboard' - }, '#duplicate-cleaner-card': { title: 'Duplicate Cleaner', description: 'Scans your library for duplicate tracks by comparing title, artist, album, and file characteristics. Reviews duplicates before taking any action.', @@ -2358,7 +2348,6 @@ const HELPER_TOURS = { // Tools — in page order { page: 'dashboard', selector: '#db-updater-card', title: 'Database Updater', description: 'Syncs your media server\'s library into SoulSync\'s database. Three modes: Incremental (fast, new content only), Full Refresh (rebuilds everything), Deep Scan (finds and removes stale entries).' }, { page: 'dashboard', selector: '#metadata-updater-card', title: 'Metadata Enrichment', description: 'Background workers that enrich your library from 9 services — Spotify, MusicBrainz, Deezer, Last.fm, iTunes, AudioDB, Genius, Tidal, Qobuz. Runs automatically at the configured interval.' }, - { page: 'dashboard', selector: '#quality-scanner-card', title: 'Quality Scanner', description: 'Analyzes audio files for quality integrity. Calculates bitrate density to detect transcodes (e.g., an MP3 re-encoded as FLAC). Scan by Full Library, New Only, or Single Artist.' }, { page: 'dashboard', selector: '#duplicate-cleaner-card', title: 'Duplicate Cleaner', description: 'Finds and removes duplicate tracks by comparing title, artist, album, and audio characteristics. Always reviews before deleting.' }, { page: 'dashboard', selector: '#discovery-pool-card', title: 'Discovery Pool', description: 'Tracks from similar artists found during watchlist scans. Matched tracks feed the Discover page playlists and genre browser. Fix failed matches manually.' }, { page: 'dashboard', selector: '#retag-tool-card', title: 'Retag Tool', description: 'Queue of tracks needing metadata corrections. When enrichment detects better tags than what\'s in your files, they appear here for batch review.' }, @@ -3386,7 +3375,7 @@ function _guessPageFromSelector(selector) { 'import': ['import-page-'], 'settings': ['settings-', 'stg-tab', 'api-service', 'server-toggle', 'save-button', 'spotify-client', 'soulseek-url', 'quality-profile'], 'issues': ['issues-'], - 'dashboard': ['dashboard-', 'service-card', 'watchlist-button', 'wishlist-button', 'db-updater', 'metadata-updater', 'quality-scanner', 'duplicate-cleaner', 'discovery-pool-card', 'retag-tool', 'media-scan', 'backup-manager', 'metadata-cache'], + 'dashboard': ['dashboard-', 'service-card', 'watchlist-button', 'wishlist-button', 'db-updater', 'metadata-updater', 'duplicate-cleaner', 'discovery-pool-card', 'retag-tool', 'media-scan', 'backup-manager', 'metadata-cache'], }; const selectorLower = selector.toLowerCase(); diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index 9cfed00f..b59ab783 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -2811,145 +2811,8 @@ function stopDbUpdatePolling() { } } -// =================================================================== -// QUALITY SCANNER TOOL -// =================================================================== - -async function handleQualityScanButtonClick() { - const button = document.getElementById('quality-scan-button'); - const currentAction = button.textContent; - - if (currentAction === 'Scan Library') { - const scopeSelect = document.getElementById('quality-scan-scope'); - const scope = scopeSelect.value; - - try { - button.disabled = true; - button.textContent = 'Starting...'; - const response = await fetch('/api/quality-scanner/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ scope: scope }) - }); - - if (response.ok) { - showToast('Quality scan started!', 'success'); - // Start polling immediately to get live status - checkAndUpdateQualityScanProgress(); - } else { - const errorData = await response.json(); - showToast(`Error: ${errorData.error}`, 'error'); - button.disabled = false; - button.textContent = 'Scan Library'; - } - } catch (error) { - showToast('Failed to start quality scan.', 'error'); - button.disabled = false; - button.textContent = 'Scan Library'; - } - - } else { // "Stop Scan" - try { - const response = await fetch('/api/quality-scanner/stop', { method: 'POST' }); - if (response.ok) { - showToast('Stop request sent.', 'info'); - } else { - showToast('Failed to send stop request.', 'error'); - } - } catch (error) { - showToast('Error sending stop request.', 'error'); - } - } -} - -async function checkAndUpdateQualityScanProgress() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/quality-scanner/status', { - signal: AbortSignal.timeout(10000) // 10 second timeout - }); - if (!response.ok) return; - - const state = await response.json(); - console.debug('🔍 Quality Scanner Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`); - updateQualityScanProgressUI(state); - - // Start polling only if not already polling and status is running - if (state.status === 'running' && !qualityScannerStatusInterval) { - console.log('🔄 Starting quality scanner polling (1 second interval)'); - qualityScannerStatusInterval = setInterval(checkAndUpdateQualityScanProgress, 1000); - } - - } catch (error) { - console.warn('Could not fetch quality scanner status:', error); - // Don't stop polling on network errors - keep trying - } -} - -function updateQualityScanProgressFromData(data) { - const prev = _lastToolStatus['quality-scanner']; - _lastToolStatus['quality-scanner'] = data.status; - if (prev !== undefined && data.status === prev && data.status !== 'running') return; - updateQualityScanProgressUI(data); -} - -function updateQualityScanProgressUI(state) { - const button = document.getElementById('quality-scan-button'); - const phaseLabel = document.getElementById('quality-phase-label'); - const progressLabel = document.getElementById('quality-progress-label'); - const progressBar = document.getElementById('quality-progress-bar'); - const scopeSelect = document.getElementById('quality-scan-scope'); - - // Stats - const processedStat = document.getElementById('quality-stat-processed'); - const metStat = document.getElementById('quality-stat-met'); - const lowStat = document.getElementById('quality-stat-low'); - const matchedStat = document.getElementById('quality-stat-matched'); - - if (!button || !phaseLabel || !progressLabel || !progressBar || !scopeSelect) return; - - // Update stats - if (processedStat) processedStat.textContent = state.processed || 0; - if (metStat) metStat.textContent = state.quality_met || 0; - if (lowStat) lowStat.textContent = state.low_quality || 0; - if (matchedStat) matchedStat.textContent = state.matched || 0; - - if (state.status === 'running') { - button.textContent = 'Stop Scan'; - button.disabled = false; - scopeSelect.disabled = true; - - phaseLabel.textContent = state.phase || 'Scanning...'; - progressLabel.textContent = `${state.processed} / ${state.total} tracks scanned (${state.progress.toFixed(1)}%)`; - progressBar.style.width = `${state.progress}%`; - } else { // idle, finished, or error - stopQualityScannerPolling(); - button.textContent = 'Scan Library'; - button.disabled = false; - scopeSelect.disabled = false; - - if (state.status === 'error') { - phaseLabel.textContent = `Error: ${state.error_message}`; - progressBar.style.backgroundColor = '#ff4444'; // Red for error - } else { - phaseLabel.textContent = state.phase || 'Ready to scan'; - progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal - } - - if (state.status === 'finished') { - // Show completion toast with results - showToast(`Scan complete! ${state.matched} tracks added to wishlist`, 'success'); - } - } -} - -function stopQualityScannerPolling() { - if (qualityScannerStatusInterval) { - console.log('⏹️ Stopping quality scanner polling'); - clearInterval(qualityScannerStatusInterval); - qualityScannerStatusInterval = null; - } -} +// (Quality Scanner tool removed — quality scanning is now the 'Quality Upgrade +// Finder' job in Library Maintenance / Tools → repair jobs.) // =================================================================== // IMPORT IDS FROM FILE TAGS (reconcile embedded provider IDs) @@ -5630,43 +5493,6 @@ const TOOL_HELP_CONTENT = {

Available for Plex and Jellyfin media servers. Each enrichment worker only runs if its service is authenticated.

` }, - 'quality-scanner': { - title: 'Quality Scanner', - content: ` -

What does this tool do?

-

The Quality Scanner identifies tracks in your library that don't meet your preferred quality settings and automatically matches them to Spotify to add to your wishlist for re-downloading.

- -

Scan Scope

- - -

How it works

-
    -
  1. Scans tracks and checks file format against your quality preferences
  2. -
  3. Identifies tracks below your quality threshold (e.g., MP3 when you prefer FLAC)
  4. -
  5. Uses fuzzy matching to find the track on Spotify (70% confidence minimum)
  6. -
  7. Automatically adds matched tracks to your wishlist for re-download
  8. -
- -

Quality Tiers

- - -

Stats Explained

- - ` - }, 'duplicate-cleaner': { title: 'Duplicate Cleaner', content: ` @@ -7566,11 +7392,6 @@ async function initializeToolsPage() { metadataButton._toolsWired = true; } - const qualityScanButton = document.getElementById('quality-scan-button'); - if (qualityScanButton && !qualityScanButton._toolsWired) { - qualityScanButton.addEventListener('click', handleQualityScanButtonClick); - qualityScanButton._toolsWired = true; - } const duplicateCleanButton = document.getElementById('duplicate-clean-button'); if (duplicateCleanButton && !duplicateCleanButton._toolsWired) { @@ -7615,7 +7436,6 @@ async function initializeToolsPage() { // Check for ongoing operations await checkAndUpdateDbProgress(); - await checkAndUpdateQualityScanProgress(); await checkAndUpdateDuplicateCleanProgress(); // Initialize library maintenance section