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", {"type": "update_discovery_pool", "label": "Update Discovery", "icon": "compass",
"description": "Refresh discovery pool with new tracks", "available": True}, "description": "Refresh discovery pool with new tracks", "available": True},
{"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart", {"type": "start_quality_scan", "label": "Run Quality Scan", "icon": "bar-chart",
"description": "Scan for low-quality audio files", "available": True, "description": "Run the Quality Upgrade Finder (scope is set in Library Maintenance)", "available": True},
"config_fields": [
{"key": "scope", "type": "select", "label": "Scope",
"options": [{"value": "watchlist", "label": "Watchlist Artists"}, {"value": "library", "label": "Full Library"}],
"default": "watchlist"}
]},
{"type": "backup_database", "label": "Backup Database", "icon": "save", {"type": "backup_database", "label": "Backup Database", "icon": "save",
"description": "Create timestamped database backup", "available": True}, "description": "Create timestamped database backup", "available": True},
{"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music", {"type": "refresh_beatport_cache", "label": "Refresh Beatport Cache", "icon": "music",

View file

@ -123,10 +123,10 @@ class AutomationDeps:
duplicate_cleaner_lock: Any duplicate_cleaner_lock: Any
duplicate_cleaner_executor: Any duplicate_cleaner_executor: Any
run_duplicate_cleaner: Callable[..., Any] run_duplicate_cleaner: Callable[..., Any]
get_quality_scanner_state: Callable[[], dict] # Triggers a "Run Now" of a library-maintenance repair job by id (e.g.
quality_scanner_lock: Any # 'quality_upgrade'). Returns truthy if the job was queued. Replaces the old
quality_scanner_executor: Any # standalone quality-scanner executor/state (the scanner is now a repair job).
run_quality_scanner: Callable[..., Any] run_repair_job_now: Callable[[str], Any]
# --- Download orchestrator + queue accessors --- # --- Download orchestrator + queue accessors ---
download_orchestrator: Any download_orchestrator: Any

View file

@ -1,83 +1,35 @@
"""Automation handler: ``start_quality_scan`` action. """Automation handler: ``start_quality_scan`` action.
Lifted from ``web_server._register_automation_handlers`` (the The quality scanner was redesigned from an auto-acting tool into the
``_auto_start_quality_scan`` closure). Submits the quality scanner ``quality_upgrade`` library-maintenance repair job (findings-based, reviewed
to its executor with the configured scope (default: ``watchlist``) before anything is wishlisted). This action now simply triggers a "Run Now" of
then polls the shared state dict. 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 from __future__ import annotations
import time
from typing import Any, Dict from typing import Any, Dict
from core.automation.deps import AutomationDeps 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]: def auto_start_quality_scan(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str, Any]:
automation_id = config.get('_automation_id') 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') triggered = deps.run_repair_job_now('quality_upgrade')
# Pre-set status before submit so the polling loop doesn't see a if not triggered:
# 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
deps.update_progress( deps.update_progress(
automation_id, automation_id, status='error', phase='Unavailable',
phase=state.get('phase', 'Scanning...'), log_line='Quality Upgrade job could not be triggered (library worker unavailable)',
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',
log_type='error', 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( deps.update_progress(
automation_id, status='finished', progress=100, automation_id, status='finished', progress=100, phase='Triggered',
phase='Complete', log_line='Quality Upgrade scan queued — findings appear in Library Maintenance',
log_line=f'Quality scan complete — {issues} issues found',
log_type='success', log_type='success',
) )
return { return {'status': 'completed', 'triggered': True, '_manages_own_progress': True}
'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),
}

View file

@ -136,7 +136,7 @@ def register_all(deps: AutomationDeps) -> None:
engine.register_action_handler( engine.register_action_handler(
'start_quality_scan', 'start_quality_scan',
lambda config: auto_start_quality_scan(config, deps), 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( engine.register_action_handler(
'backup_database', '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 These were the matching guts of the old auto-acting quality-scanner worker (now
quality-scanner endpoint kicks off in a thread to scan the library removed quality scanning is the ``quality_upgrade`` library-maintenance repair
for low-quality tracks (below the user's configured quality profile) job in ``core/repair_jobs/quality_upgrade.py``). They're kept here as a single
and add provider matches to the wishlist: source of truth and imported by that job:
1. Reset scanner state, load quality profile + minimum acceptable tier. - ``_search_tracks_for_source`` query one metadata source's ``search_tracks``.
2. Load tracks from DB based on scope: - ``_normalize_track_match`` / ``_normalize_track_album`` / ``_normalize_track_artists``
- 'watchlist' tracks for watchlisted artists only. turn a provider track into the wishlist-ready dict (typed Album converters
- other all library tracks. with legacy duck-typed fallback).
3. For each track: - ``_track_name`` / ``_track_artist_names`` / ``_extract_lookup_value`` accessors.
- 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.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Callable, Dict, Optional 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.metadata.types import Album
from core.wishlist.payloads import ensure_wishlist_track_format 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: 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: except Exception as exc:
logger.debug("Could not search %s for %s: %s", source, query, exc) logger.debug("Could not search %s for %s: %s", source, query, exc)
return [] 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_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),
@ -360,7 +357,6 @@ class TestHandlerInvocation:
**{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()}, **{f.name: getattr(deps, f.name) for f in deps.__dataclass_fields__.values()},
'get_db_update_state': lambda: running_state, 'get_db_update_state': lambda: running_state,
'get_duplicate_cleaner_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_download_batches': lambda: active_batches, # forces clean_completed_downloads to skip
'get_database': lambda: _StubDB(), 'get_database': lambda: _StubDB(),
'get_app': lambda: _StubApp(), 'get_app': lambda: _StubApp(),

View file

@ -89,10 +89,7 @@ def _build_deps(**overrides) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),
@ -185,11 +182,18 @@ class TestDuplicateCleaner:
class TestQualityScanner: class TestQualityScanner:
def test_already_running_returns_skipped(self): def test_triggers_quality_upgrade_repair_job(self):
state = {'status': 'running'} triggered = []
deps = _build_deps(get_quality_scanner_state=lambda: state) deps = _build_deps(run_repair_job_now=lambda job_id: triggered.append(job_id) or True)
result = auto_start_quality_scan({}, deps) 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 ──────────────────────────────────────────────── # ─── clear_quarantine ────────────────────────────────────────────────

View file

@ -63,10 +63,7 @@ def _build_deps(**overrides) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -125,10 +125,7 @@ def _build_deps(**overrides) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -76,10 +76,7 @@ def _build_deps(**overrides: Any) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -41,10 +41,7 @@ def _minimal_deps(**overrides):
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -62,10 +62,7 @@ def _build_deps(**overrides) -> AutomationDeps:
duplicate_cleaner_lock=threading.Lock(), duplicate_cleaner_lock=threading.Lock(),
duplicate_cleaner_executor=None, duplicate_cleaner_executor=None,
run_duplicate_cleaner=lambda: None, run_duplicate_cleaner=lambda: None,
get_quality_scanner_state=lambda: {}, run_repair_job_now=lambda *a, **k: True,
quality_scanner_lock=threading.Lock(),
quality_scanner_executor=None,
run_quality_scanner=lambda *a, **k: None,
download_orchestrator=None, download_orchestrator=None,
run_async=lambda coro: None, run_async=lambda coro: None,
tasks_lock=threading.Lock(), tasks_lock=threading.Lock(),

View file

@ -113,12 +113,6 @@ _DEFAULT_STREAM_STATE = {
"error_message": None, "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 = { _DEFAULT_DUPLICATE_CLEANER_STATE = {
"status": "running", "phase": "Scanning...", "progress": 50, "status": "running", "phase": "Scanning...", "progress": 50,
"files_scanned": 500, "total_files": 1000, "duplicates_found": 10, "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 # Phase 4: Tool progress state
stream_state = copy.deepcopy(_DEFAULT_STREAM_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) duplicate_cleaner_state = copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)
retag_state = copy.deepcopy(_DEFAULT_RETAG_STATE) retag_state = copy.deepcopy(_DEFAULT_RETAG_STATE)
db_update_state = copy.deepcopy(_DEFAULT_DB_UPDATE_STATE) db_update_state = copy.deepcopy(_DEFAULT_DB_UPDATE_STATE)
@ -350,13 +343,12 @@ ENRICHMENT_ENDPOINTS = {
# Phase 4 helpers # Phase 4 helpers
TOOL_NAMES = [ TOOL_NAMES = [
'stream', 'quality-scanner', 'duplicate-cleaner', 'stream', 'duplicate-cleaner',
'retag', 'db-update', 'metadata', 'logs', 'retag', 'db-update', 'metadata', 'logs',
] ]
TOOL_ENDPOINTS = { TOOL_ENDPOINTS = {
'stream': '/api/stream/status', 'stream': '/api/stream/status',
'quality-scanner': '/api/quality-scanner/status',
'duplicate-cleaner': '/api/duplicate-cleaner/status', 'duplicate-cleaner': '/api/duplicate-cleaner/status',
'retag': '/api/retag/status', 'retag': '/api/retag/status',
'db-update': '/api/database/update/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(): def _build_duplicate_cleaner_status():
state_copy = duplicate_cleaner_state.copy() state_copy = duplicate_cleaner_state.copy()
state_copy["space_freed_mb"] = duplicate_cleaner_state["space_freed"] / (1024 * 1024) 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.""" """Dispatcher that returns the correct status payload for any tool."""
builders = { builders = {
'stream': _build_stream_status, 'stream': _build_stream_status,
'quality-scanner': _build_quality_scanner_status,
'duplicate-cleaner': _build_duplicate_cleaner_status, 'duplicate-cleaner': _build_duplicate_cleaner_status,
'retag': _build_retag_status, 'retag': _build_retag_status,
'db-update': _build_db_update_status, 'db-update': _build_db_update_status,
@ -635,10 +622,6 @@ def test_app():
def stream_status_endpoint(): def stream_status_endpoint():
return jsonify(_build_stream_status()) 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') @app.route('/api/duplicate-cleaner/status')
def duplicate_cleaner_status_endpoint(): def duplicate_cleaner_status_endpoint():
return jsonify(_build_duplicate_cleaner_status()) return jsonify(_build_duplicate_cleaner_status())
@ -961,7 +944,6 @@ def shared_state():
'enrichment_endpoints': ENRICHMENT_ENDPOINTS, 'enrichment_endpoints': ENRICHMENT_ENDPOINTS,
# Phase 4 state # Phase 4 state
'stream_state': stream_state, 'stream_state': stream_state,
'quality_scanner_state': quality_scanner_state,
'duplicate_cleaner_state': duplicate_cleaner_state, 'duplicate_cleaner_state': duplicate_cleaner_state,
'retag_state': retag_state, 'retag_state': retag_state,
'db_update_state': db_update_state, 'db_update_state': db_update_state,
@ -969,7 +951,6 @@ def shared_state():
'logs_activities': logs_activities, 'logs_activities': logs_activities,
'build_tool_status': _build_tool_status, 'build_tool_status': _build_tool_status,
'build_stream_status': _build_stream_status, 'build_stream_status': _build_stream_status,
'build_quality_scanner_status': _build_quality_scanner_status,
'build_duplicate_cleaner_status': _build_duplicate_cleaner_status, 'build_duplicate_cleaner_status': _build_duplicate_cleaner_status,
'build_retag_status': _build_retag_status, 'build_retag_status': _build_retag_status,
'build_db_update_status': _build_db_update_status, 'build_db_update_status': _build_db_update_status,
@ -1019,8 +1000,6 @@ def reset_state():
# Phase 4 resets # Phase 4 resets
stream_state.clear() stream_state.clear()
stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) 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.clear()
duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE))
retag_state.clear() retag_state.clear()
@ -1061,8 +1040,6 @@ def reset_state():
enrichment_status.update(copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS)) enrichment_status.update(copy.deepcopy(_DEFAULT_ENRICHMENT_STATUS))
stream_state.clear() stream_state.clear()
stream_state.update(copy.deepcopy(_DEFAULT_STREAM_STATE)) 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.clear()
duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE)) duplicate_cleaner_state.update(copy.deepcopy(_DEFAULT_DUPLICATE_CLEANER_STATE))
retag_state.clear() 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 import pytest
# All 7 tool progress pollers # Tool progress pollers
TOOLS = [ TOOLS = [
'stream', 'quality-scanner', 'duplicate-cleaner', 'stream', 'duplicate-cleaner',
'retag', 'db-update', 'metadata', 'logs', 'retag', 'db-update', 'metadata', 'logs',
] ]
# Endpoint URLs keyed by tool name # Endpoint URLs keyed by tool name
ENDPOINTS = { ENDPOINTS = {
'stream': '/api/stream/status', 'stream': '/api/stream/status',
'quality-scanner': '/api/quality-scanner/status',
'duplicate-cleaner': '/api/duplicate-cleaner/status', 'duplicate-cleaner': '/api/duplicate-cleaner/status',
'retag': '/api/retag/status', 'retag': '/api/retag/status',
'db-update': '/api/database/update/status', 'db-update': '/api/database/update/status',
@ -63,27 +62,6 @@ class TestToolDataShape:
assert 'error_message' in data assert 'error_message' in data
assert isinstance(data['progress'], (int, float)) 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): def test_duplicate_cleaner_shape(self, test_app, shared_state):
"""Duplicate cleaner has status, phase, progress, space_freed_mb.""" """Duplicate cleaner has status, phase, progress, space_freed_mb."""
app, socketio = test_app app, socketio = test_app
@ -255,11 +233,11 @@ class TestToolBackwardCompat:
client2 = socketio.test_client(app) client2 = socketio.test_client(app)
build = shared_state['build_tool_status'] 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]: for client in [client1, client2]:
received = client.get_received() 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 assert len(events) >= 1
client1.disconnect() client1.disconnect()
@ -270,17 +248,15 @@ class TestToolBackwardCompat:
app, socketio = test_app app, socketio = test_app
client = socketio.test_client(app) client = socketio.test_client(app)
build = shared_state['build_tool_status'] build = shared_state['build_tool_status']
qs = shared_state['quality_scanner_state'] dc = shared_state['duplicate_cleaner_state']
# Mutate state # Mutate state
qs['status'] = 'finished' dc['status'] = 'finished'
qs['progress'] = 100 dc['progress'] = 100
qs['processed'] = 100
socketio.emit('tool:quality-scanner', build('quality-scanner')) socketio.emit('tool:duplicate-cleaner', build('duplicate-cleaner'))
received = client.get_received() 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] data = events[-1]['args'][0]
assert data['status'] == 'finished' assert data['status'] == 'finished'
assert data['progress'] == 100 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 global _db_update_automation_id
_db_update_automation_id = value _db_update_automation_id = value
# Quality Scanner state # Quality scanning is now the 'quality_upgrade' library-maintenance repair job
quality_scanner_state = { # (core/repair_jobs/quality_upgrade.py) — no standalone state/executor here.
"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")
# Duplicate Cleaner state # Duplicate Cleaner state
duplicate_cleaner_state = { duplicate_cleaner_state = {
@ -1253,10 +1240,7 @@ def _register_automation_handlers():
duplicate_cleaner_lock=duplicate_cleaner_lock, duplicate_cleaner_lock=duplicate_cleaner_lock,
duplicate_cleaner_executor=duplicate_cleaner_executor, duplicate_cleaner_executor=duplicate_cleaner_executor,
run_duplicate_cleaner=_run_duplicate_cleaner, run_duplicate_cleaner=_run_duplicate_cleaner,
get_quality_scanner_state=lambda: quality_scanner_state, run_repair_job_now=lambda job_id: repair_worker.run_job_now(job_id) if repair_worker else None,
quality_scanner_lock=quality_scanner_lock,
quality_scanner_executor=quality_scanner_executor,
run_quality_scanner=_run_quality_scanner,
download_orchestrator=download_orchestrator, download_orchestrator=download_orchestrator,
run_async=run_async, run_async=run_async,
tasks_lock=tasks_lock, tasks_lock=tasks_lock,
@ -1859,7 +1843,6 @@ def _shutdown_runtime_components():
for executor, name in [ for executor, name in [
(stream_executor, "stream executor"), (stream_executor, "stream executor"),
(db_update_executor, "db update executor"), (db_update_executor, "db update executor"),
(quality_scanner_executor, "quality scanner executor"),
(duplicate_cleaner_executor, "duplicate cleaner executor"), (duplicate_cleaner_executor, "duplicate cleaner executor"),
(sync_executor, "sync executor"), (sync_executor, "sync executor"),
(missing_download_executor, "missing download executor"), (missing_download_executor, "missing download executor"),
@ -17639,28 +17622,7 @@ def _get_quality_tier_from_extension(file_path):
return ('unknown', 999) return ('unknown', 999)
# Quality scanner worker logic lives in core/discovery/quality_scanner.py. # (Quality scanning moved to the 'quality_upgrade' library-maintenance repair job.)
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()
)
from core.library.duplicate_cleaner import ( from core.library.duplicate_cleaner import (
_run_duplicate_cleaner, _run_duplicate_cleaner,
@ -17673,55 +17635,6 @@ _init_duplicate_cleaner(
engine=automation_engine, 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']) @app.route('/api/duplicate-cleaner/start', methods=['POST'])
def start_duplicate_cleaner(): def start_duplicate_cleaner():
"""Start the 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 # (which skipped HTTP polling while the socket was up) never learned
# its stream was ready. Each client polls /api/stream/status instead, # its stream was ready. Each client polls /api/stream/status instead,
# which resolves its own session from the cookie. # 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) # Duplicate Cleaner (add computed space_freed_mb)
try: try:
with duplicate_cleaner_lock: with duplicate_cleaner_lock:

View file

@ -6831,49 +6831,6 @@
</div> </div>
</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" id="reconcile-ids-card">
<div class="tool-card-header"> <div class="tool-card-header">
<h4 class="tool-card-title">Import IDs from File Tags</h4> <h4 class="tool-card-title">Import IDs from File Tags</h4>

View file

@ -29,7 +29,6 @@ let isSortReversed = false;
let searchAbortController = null; let searchAbortController = null;
let dbStatsInterval = null; let dbStatsInterval = null;
let dbUpdateStatusInterval = null; let dbUpdateStatusInterval = null;
let qualityScannerStatusInterval = null;
let duplicateCleanerStatusInterval = null; let duplicateCleanerStatusInterval = null;
let wishlistCountInterval = null; let wishlistCountInterval = null;
let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal 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 // 'tool:stream' is intentionally NOT wired: stream state is per-listener
// (session cookie), so the global broadcast could only carry the DEFAULT // (session cookie), so the global broadcast could only carry the DEFAULT
// session's eternal "stopped" — the player polls /api/stream/status instead. // 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: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:db-update', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateDbProgressFromData(data); });
socket.on('tool:metadata', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateMetadataStatusFromData(data); }); socket.on('tool:metadata', (data) => { if (_qaToolBusy(data)) qaSignal('tools'); updateMetadataStatusFromData(data); });

View file

@ -328,16 +328,6 @@ const HELPER_CONTENT = {
], ],
docsId: 'dashboard' 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': { '#duplicate-cleaner-card': {
title: 'Duplicate Cleaner', title: 'Duplicate Cleaner',
description: 'Scans your library for duplicate tracks by comparing title, artist, album, and file characteristics. Reviews duplicates before taking any action.', 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 // 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: '#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: '#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: '#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: '#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.' }, { 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-'], 'import': ['import-page-'],
'settings': ['settings-', 'stg-tab', 'api-service', 'server-toggle', 'save-button', 'spotify-client', 'soulseek-url', 'quality-profile'], 'settings': ['settings-', 'stg-tab', 'api-service', 'server-toggle', 'save-button', 'spotify-client', 'soulseek-url', 'quality-profile'],
'issues': ['issues-'], '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(); const selectorLower = selector.toLowerCase();

View file

@ -2811,145 +2811,8 @@ function stopDbUpdatePolling() {
} }
} }
// =================================================================== // (Quality Scanner tool removed — quality scanning is now the 'Quality Upgrade
// QUALITY SCANNER TOOL // Finder' job in Library Maintenance / Tools → repair jobs.)
// ===================================================================
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;
}
}
// =================================================================== // ===================================================================
// IMPORT IDS FROM FILE TAGS (reconcile embedded provider IDs) // 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> <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': { 'duplicate-cleaner': {
title: 'Duplicate Cleaner', title: 'Duplicate Cleaner',
content: ` content: `
@ -7566,11 +7392,6 @@ async function initializeToolsPage() {
metadataButton._toolsWired = true; 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'); const duplicateCleanButton = document.getElementById('duplicate-clean-button');
if (duplicateCleanButton && !duplicateCleanButton._toolsWired) { if (duplicateCleanButton && !duplicateCleanButton._toolsWired) {
@ -7615,7 +7436,6 @@ async function initializeToolsPage() {
// Check for ongoing operations // Check for ongoing operations
await checkAndUpdateDbProgress(); await checkAndUpdateDbProgress();
await checkAndUpdateQualityScanProgress();
await checkAndUpdateDuplicateCleanProgress(); await checkAndUpdateDuplicateCleanProgress();
// Initialize library maintenance section // Initialize library maintenance section