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:
parent
69dd4e1792
commit
b393866782
20 changed files with 66 additions and 1389 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
101
web_server.py
101
web_server.py
|
|
@ -990,21 +990,8 @@ def _set_db_update_automation_id(value):
|
|||
global _db_update_automation_id
|
||||
_db_update_automation_id = value
|
||||
|
||||
# Quality Scanner state
|
||||
quality_scanner_state = {
|
||||
"status": "idle", # idle, running, finished, error
|
||||
"phase": "Ready to scan",
|
||||
"progress": 0,
|
||||
"processed": 0,
|
||||
"total": 0,
|
||||
"quality_met": 0,
|
||||
"low_quality": 0,
|
||||
"matched": 0,
|
||||
"error_message": "",
|
||||
"results": [], # List of low quality tracks with match status
|
||||
}
|
||||
quality_scanner_lock = threading.Lock()
|
||||
quality_scanner_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="QualityScanner")
|
||||
# Quality scanning is now the 'quality_upgrade' library-maintenance repair job
|
||||
# (core/repair_jobs/quality_upgrade.py) — no standalone state/executor here.
|
||||
|
||||
# Duplicate Cleaner state
|
||||
duplicate_cleaner_state = {
|
||||
|
|
@ -1253,10 +1240,7 @@ def _register_automation_handlers():
|
|||
duplicate_cleaner_lock=duplicate_cleaner_lock,
|
||||
duplicate_cleaner_executor=duplicate_cleaner_executor,
|
||||
run_duplicate_cleaner=_run_duplicate_cleaner,
|
||||
get_quality_scanner_state=lambda: quality_scanner_state,
|
||||
quality_scanner_lock=quality_scanner_lock,
|
||||
quality_scanner_executor=quality_scanner_executor,
|
||||
run_quality_scanner=_run_quality_scanner,
|
||||
run_repair_job_now=lambda job_id: repair_worker.run_job_now(job_id) if repair_worker else None,
|
||||
download_orchestrator=download_orchestrator,
|
||||
run_async=run_async,
|
||||
tasks_lock=tasks_lock,
|
||||
|
|
@ -1859,7 +1843,6 @@ def _shutdown_runtime_components():
|
|||
for executor, name in [
|
||||
(stream_executor, "stream executor"),
|
||||
(db_update_executor, "db update executor"),
|
||||
(quality_scanner_executor, "quality scanner executor"),
|
||||
(duplicate_cleaner_executor, "duplicate cleaner executor"),
|
||||
(sync_executor, "sync executor"),
|
||||
(missing_download_executor, "missing download executor"),
|
||||
|
|
@ -17639,28 +17622,7 @@ def _get_quality_tier_from_extension(file_path):
|
|||
|
||||
return ('unknown', 999)
|
||||
|
||||
# Quality scanner worker logic lives in core/discovery/quality_scanner.py.
|
||||
from core.discovery import quality_scanner as _discovery_quality_scanner
|
||||
|
||||
|
||||
def _build_quality_scanner_deps():
|
||||
"""Build the QualityScannerDeps bundle from web_server.py globals on each call."""
|
||||
return _discovery_quality_scanner.QualityScannerDeps(
|
||||
quality_scanner_state=quality_scanner_state,
|
||||
quality_scanner_lock=quality_scanner_lock,
|
||||
QUALITY_TIERS=QUALITY_TIERS,
|
||||
matching_engine=matching_engine,
|
||||
automation_engine=automation_engine,
|
||||
get_quality_tier_from_extension=_get_quality_tier_from_extension,
|
||||
add_activity_item=add_activity_item,
|
||||
)
|
||||
|
||||
|
||||
def _run_quality_scanner(scope='watchlist', profile_id=1):
|
||||
return _discovery_quality_scanner.run_quality_scanner(
|
||||
scope, profile_id, _build_quality_scanner_deps()
|
||||
)
|
||||
|
||||
# (Quality scanning moved to the 'quality_upgrade' library-maintenance repair job.)
|
||||
|
||||
from core.library.duplicate_cleaner import (
|
||||
_run_duplicate_cleaner,
|
||||
|
|
@ -17673,55 +17635,6 @@ _init_duplicate_cleaner(
|
|||
engine=automation_engine,
|
||||
)
|
||||
|
||||
@app.route('/api/quality-scanner/start', methods=['POST'])
|
||||
def start_quality_scan():
|
||||
"""Start the quality scanner"""
|
||||
with quality_scanner_lock:
|
||||
if quality_scanner_state["status"] == "running":
|
||||
return jsonify({"success": False, "error": "A scan is already in progress"}), 409
|
||||
|
||||
data = request.get_json() or {}
|
||||
scope = data.get('scope', 'watchlist') # 'watchlist' or 'all'
|
||||
|
||||
logger.info(f"[Quality Scanner API] Starting scan with scope: {scope}")
|
||||
|
||||
# Reset state
|
||||
quality_scanner_state["status"] = "running"
|
||||
quality_scanner_state["phase"] = "Initializing..."
|
||||
quality_scanner_state["progress"] = 0
|
||||
quality_scanner_state["processed"] = 0
|
||||
quality_scanner_state["total"] = 0
|
||||
quality_scanner_state["quality_met"] = 0
|
||||
quality_scanner_state["low_quality"] = 0
|
||||
quality_scanner_state["matched"] = 0
|
||||
quality_scanner_state["results"] = []
|
||||
quality_scanner_state["error_message"] = ""
|
||||
|
||||
# Submit worker (capture profile_id before thread)
|
||||
scan_profile_id = get_current_profile_id()
|
||||
quality_scanner_executor.submit(_run_quality_scanner, scope, scan_profile_id)
|
||||
|
||||
add_activity_item("", "Quality Scan Started", f"Scanning {scope} tracks", "Now")
|
||||
|
||||
return jsonify({"success": True, "message": "Quality scan started"})
|
||||
|
||||
@app.route('/api/quality-scanner/status', methods=['GET'])
|
||||
def get_quality_scanner_status():
|
||||
"""Get current quality scanner status"""
|
||||
with quality_scanner_lock:
|
||||
return jsonify(quality_scanner_state)
|
||||
|
||||
@app.route('/api/quality-scanner/stop', methods=['POST'])
|
||||
def stop_quality_scan():
|
||||
"""Stop the quality scanner (sets a stop flag)"""
|
||||
with quality_scanner_lock:
|
||||
if quality_scanner_state["status"] == "running":
|
||||
quality_scanner_state["status"] = "finished"
|
||||
quality_scanner_state["phase"] = "Scan stopped by user"
|
||||
return jsonify({"success": True, "message": "Stop request sent"})
|
||||
else:
|
||||
return jsonify({"success": False, "error": "No scan is currently running"}), 404
|
||||
|
||||
@app.route('/api/duplicate-cleaner/start', methods=['POST'])
|
||||
def start_duplicate_cleaner():
|
||||
"""Start the duplicate cleaner"""
|
||||
|
|
@ -37341,12 +37254,6 @@ def _emit_tool_progress_loop():
|
|||
# (which skipped HTTP polling while the socket was up) never learned
|
||||
# its stream was ready. Each client polls /api/stream/status instead,
|
||||
# which resolves its own session from the cookie.
|
||||
# Quality Scanner
|
||||
try:
|
||||
with quality_scanner_lock:
|
||||
socketio.emit('tool:quality-scanner', dict(quality_scanner_state))
|
||||
except Exception as e:
|
||||
logger.debug(f"Error emitting quality scanner status: {e}")
|
||||
# Duplicate Cleaner (add computed space_freed_mb)
|
||||
try:
|
||||
with duplicate_cleaner_lock:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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); });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue