Remove old auto-acting Quality Scanner tool (replaced by Quality Upgrade Finder job)

Phase 2 of the redesign. The tool that judged quality by extension and auto-dumped
matches into the wishlist is gone; quality scanning is now the reviewed
quality_upgrade repair job.

Removed:
- Frontend: Tools-page Quality Scanner card, its JS handlers/poller/socket listener,
  help tooltip + tour entry (webui index.html, core.js, helper.js, wishlist-tools.js).
- Backend: /api/quality-scanner/{start,status,stop} endpoints, the in-memory state +
  executor + 1s socket broadcast, the QualityScannerDeps/run_quality_scanner shim.
- core/discovery/quality_scanner.py: the auto-acting worker + deps class (the shared
  match/normalize helpers stay — the new job imports them).

Rewired:
- Automation 'start_quality_scan' action now triggers the quality_upgrade repair job
  via repair_worker.run_job_now() (AutomationDeps gains run_repair_job_now, drops the
  4 scanner fields). Action block's vestigial scope field removed (scope lives in the
  job's settings now). NOTE: the 'quality_scan_completed' trigger no longer fires (the
  repair job doesn't emit it).
- Updated all automation test _build_deps helpers + conftest tool-progress harness;
  deleted the obsolete worker test. 528 affected tests pass; 6123 collect cleanly.

QUALITY_TIERS / _get_quality_tier_from_extension kept (used elsewhere).
This commit is contained in:
BoulderBadgeDad 2026-06-13 12:14:15 -07:00
parent 69dd4e1792
commit b393866782
20 changed files with 66 additions and 1389 deletions

View file

@ -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",

View file

@ -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

View file

@ -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}

View file

@ -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',

View file

@ -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)}"

View file

@ -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(),

View file

@ -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 ────────────────────────────────────────────────

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -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(),

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -6831,49 +6831,6 @@
</div>
</div>
<div class="tool-card" id="quality-scanner-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Quality Scanner</h4>
<button class="tool-help-button" data-tool="quality-scanner"
title="Learn more about this tool">?</button>
</div>
<p class="tool-card-info">Scan library for tracks below quality preferences</p>
<div class="tool-card-stats">
<div class="stat-item">
<span class="stat-item-label">Processed:</span>
<span class="stat-item-value" id="quality-stat-processed">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Quality Met:</span>
<span class="stat-item-value" id="quality-stat-met">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Low Quality:</span>
<span class="stat-item-value" id="quality-stat-low">0</span>
</div>
<div class="stat-item">
<span class="stat-item-label">Matched:</span>
<span class="stat-item-value" id="quality-stat-matched">0</span>
</div>
</div>
<div class="tool-card-controls">
<select id="quality-scan-scope">
<option value="watchlist">Watchlist Artists Only</option>
<option value="all">All Library Tracks</option>
</select>
<button id="quality-scan-button">Scan Library</button>
</div>
<div class="tool-card-progress-section">
<p class="progress-phase-label" id="quality-phase-label">Ready to scan</p>
<div class="progress-bar-container">
<div class="progress-bar-fill" id="quality-progress-bar" style="width: 0%;">
</div>
</div>
<p class="progress-details-label" id="quality-progress-label">0 / 0 tracks scanned
(0.0%)</p>
</div>
</div>
<div class="tool-card" id="reconcile-ids-card">
<div class="tool-card-header">
<h4 class="tool-card-title">Import IDs from File Tags</h4>

View file

@ -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); });

View file

@ -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();

View file

@ -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 = {
<p>Available for <strong>Plex</strong> and <strong>Jellyfin</strong> media servers. Each enrichment worker only runs if its service is authenticated.</p>
`
},
'quality-scanner': {
title: 'Quality Scanner',
content: `
<h4>What does this tool do?</h4>
<p>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.</p>
<h4>Scan Scope</h4>
<ul>
<li><strong>Watchlist Artists Only:</strong> Only scans tracks from artists you're watching. Faster and more focused.</li>
<li><strong>All Library Tracks:</strong> Scans your entire music library. Comprehensive but takes longer.</li>
</ul>
<h4>How it works</h4>
<ol>
<li>Scans tracks and checks file format against your quality preferences</li>
<li>Identifies tracks below your quality threshold (e.g., MP3 when you prefer FLAC)</li>
<li>Uses fuzzy matching to find the track on Spotify (70% confidence minimum)</li>
<li>Automatically adds matched tracks to your wishlist for re-download</li>
</ol>
<h4>Quality Tiers</h4>
<ul>
<li><strong>Tier 1 (Best):</strong> FLAC, WAV, ALAC, AIFF - Lossless formats</li>
<li><strong>Tier 2:</strong> OPUS, OGG - High quality lossy</li>
<li><strong>Tier 3:</strong> M4A, AAC - Standard lossy</li>
<li><strong>Tier 4:</strong> MP3, WMA - Lower quality lossy</li>
</ul>
<h4>Stats Explained</h4>
<ul>
<li><strong>Processed:</strong> Total tracks scanned so far</li>
<li><strong>Quality Met:</strong> Tracks that meet your quality standards</li>
<li><strong>Low Quality:</strong> Tracks below your quality threshold</li>
<li><strong>Matched:</strong> Low quality tracks successfully matched to Spotify and added to wishlist</li>
</ul>
`
},
'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