soulsync/tests/discovery/test_discovery_quality_scanner.py
Broque Thomas a38bfcba55 PR5h: lift _run_quality_scanner to core/discovery/quality_scanner.py
Final lift in the PR5 discovery-workers series. Pulls the 328-line
library quality scanner out of `web_server.py` into its own focused
module under `core/discovery/`. Pure 1:1 lift — wrapper keeps the
original entry-point name.

What the quality scanner does:

1. Reset scanner state (counters, results), load quality profile +
   minimum acceptable tier from QUALITY_TIERS.
2. Load tracks from DB based on scope:
   - 'watchlist' → tracks for watchlisted artists only.
   - other → all library tracks.
3. For each track:
   - Stop-request gate (state['status'] != 'running').
   - Quality-tier check via _get_quality_tier_from_extension(file_path).
   - Skip tracks meeting standards (tier_num <= min_acceptable_tier).
   - For low-quality tracks: matching_engine search query gen, score
     candidates against Spotify (artist + title similarity, album-type
     bonus), pick best match >= 0.7 confidence.
   - On match: add full Spotify track to wishlist via
     `wishlist_service.add_spotify_track_to_wishlist` with
     source_type='quality_scanner' and a source_context that captures
     original file_path, format tier, bitrate, and match confidence.
4. After all tracks: status='finished', progress=100, activity feed
   entry, emit `quality_scan_completed` event for automation engine.
5. On critical exception: status='error', error message captured.

Wishlist service interaction is via the public
`add_spotify_track_to_wishlist` API only — no overlap with kettui's
planned `core/wishlist/` package extraction (the import lives inside
the function, exactly as in the original, and will follow whatever
path that package takes).

Dependencies injected via `QualityScannerDeps` (8 fields) —
quality_scanner_state dict, quality_scanner_lock, QUALITY_TIERS
constant, spotify_client, matching_engine, automation_engine, plus 2
callable helpers (get_quality_tier_from_extension, add_activity_item).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 328 lines orig = 328 lines lifted, byte-identical body
(including all whitespace, comments, log strings, and the inline
`from core.wishlist_service import get_wishlist_service` /
`from database.music_database import MusicDatabase` imports at the
top of the function).

Tests: 11 new under tests/discovery/test_discovery_quality_scanner.py
covering state init/reset, no-watchlist-artists short-circuit,
unauthenticated Spotify error, high-quality skip, low-quality search
trigger, match → wishlist add (with full source_context payload),
no-match no-add, mid-loop stop request, completion phase + progress,
automation engine event emission, all-library scope load.

Full suite: 1152 passing (was 1141). Ruff clean.

End of the PR5 series — `web_server.py` lost ~328 lines on this commit
alone; total trim across PR5a–PR5h is ~2,400 lines of discovery worker
code moved into focused `core/discovery/*.py` modules. The remaining
discovery-adjacent worker `_process_watchlist_scan_automatically` was
deliberately deferred to avoid overlap with kettui's planned wishlist
extraction.
2026-04-28 18:41:29 -07:00

361 lines
12 KiB
Python

"""Tests for core/discovery/quality_scanner.py — library quality scanner."""
from __future__ import annotations
import threading
from dataclasses import dataclass
from unittest.mock import MagicMock
import pytest
from core.discovery import quality_scanner as qs
# ---------------------------------------------------------------------------
# Fakes
# ---------------------------------------------------------------------------
@dataclass
class _FakeSpotifyTrack:
id: str = 'spt-1'
name: str = 'Found'
artists: list = None
album: str = 'Found Album'
duration_ms: int = 200000
popularity: int = 50
preview_url: str = ''
external_urls: dict = None
album_type: str = 'album'
release_date: str = '2024-01-01'
def __post_init__(self):
if self.artists is None:
self.artists = ['Found Artist']
if self.external_urls is None:
self.external_urls = {}
class _FakeSpotifyClient:
def __init__(self, results=None, authenticated=True):
self._results = results if results is not None else []
self._authenticated = authenticated
self.search_calls = []
def is_spotify_authenticated(self):
return self._authenticated
def search_tracks(self, query, limit=5):
self.search_calls.append((query, limit))
return self._results
class _FakeMatchingEngine:
def generate_download_queries(self, t):
return [f"{t.artists[0]} {t.name}"]
def normalize_string(self, s):
return (s or '').lower().strip()
def similarity_score(self, a, b):
if a == b:
return 1.0
if not a or not b:
return 0.0
return 0.95 if a in b or b in a else 0.0
class _FakeAutomationEngine:
def __init__(self):
self.events = []
def emit(self, event_type, data):
self.events.append((event_type, data))
class _FakeWishlistService:
def __init__(self):
self.added = []
def add_spotify_track_to_wishlist(self, **kwargs):
self.added.append(kwargs)
return True
class _FakeMusicDB:
def __init__(self, watchlist_artists=None, tracks=None, profile=None):
self._watchlist_artists = watchlist_artists if watchlist_artists is not None else []
self._tracks = tracks if tracks is not None else []
self._profile = profile or {'qualities': {'flac': {'enabled': True}}}
def get_quality_profile(self):
return self._profile
def get_watchlist_artists(self, profile_id=1):
return self._watchlist_artists
def _get_connection(self):
rows = self._tracks
return _FakeConn(rows)
class _FakeConn:
def __init__(self, rows):
self._rows = rows
def execute(self, query, params=None):
return _FakeCursor(self._rows)
def close(self):
pass
class _FakeCursor:
def __init__(self, rows):
self._rows = rows
def fetchall(self):
return self._rows
@dataclass
class _WatchlistArtist:
artist_name: str
def _build_deps(
*,
state=None,
spotify_results=None,
spotify_auth=True,
quality_tier_result=('lossless', 1),
automation=None,
):
deps = qs.QualityScannerDeps(
quality_scanner_state=state if state is not None else {},
quality_scanner_lock=threading.Lock(),
QUALITY_TIERS={'lossless': {'tier': 1}, 'low_lossy': {'tier': 4}, 'lossy': {'tier': 3}},
spotify_client=_FakeSpotifyClient(results=spotify_results or [], authenticated=spotify_auth),
matching_engine=_FakeMatchingEngine(),
automation_engine=automation or _FakeAutomationEngine(),
get_quality_tier_from_extension=lambda fp: quality_tier_result,
add_activity_item=lambda *a, **kw: None,
)
return deps
def _track_row(track_id=1, title='Track', artist_id=1, album_id=1,
file_path='/x.mp3', bitrate=128, artist_name='Artist',
album_title='Album'):
return (track_id, title, artist_id, album_id, file_path, bitrate, artist_name, album_title)
@pytest.fixture
def mock_db_and_wishlist(monkeypatch):
"""Patches MusicDatabase and get_wishlist_service used inside the worker."""
db = _FakeMusicDB()
ws = _FakeWishlistService()
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
monkeypatch.setattr('core.wishlist_service.get_wishlist_service', lambda: ws)
return db, ws
# ---------------------------------------------------------------------------
# State init + DB load
# ---------------------------------------------------------------------------
def test_state_initialized_on_run(mock_db_and_wishlist):
"""Scanner resets state to running with cleared counters."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [] # no artists → exits early but after init
state = {}
deps = _build_deps(state=state)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['status'] == 'finished' # exited early since no artists
assert state['error_message'] == 'Please add artists to watchlist first'
def test_no_watchlist_artists_short_circuit(mock_db_and_wishlist):
"""Scope=watchlist with no artists → status=finished, error message."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = []
state = {}
deps = _build_deps(state=state)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['status'] == 'finished'
assert 'add artists' in state['error_message']
# ---------------------------------------------------------------------------
# Spotify auth gate
# ---------------------------------------------------------------------------
def test_unauthenticated_spotify_marks_error(mock_db_and_wishlist):
"""Spotify not authenticated → state['status']='error'."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row()]
state = {}
deps = _build_deps(state=state, spotify_auth=False)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['status'] == 'error'
assert 'authenticate' in state['error_message'].lower()
# ---------------------------------------------------------------------------
# Quality tier check + skip
# ---------------------------------------------------------------------------
def test_high_quality_tracks_skipped(mock_db_and_wishlist):
"""Tracks meeting quality (tier_num <= min_acceptable) → quality_met += 1."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row(file_path='/x.flac')]
state = {}
# Default min_acceptable is from {flac: enabled} → tier 1 (lossless)
# quality_tier_result=('lossless', 1) → 1 <= 1 → skip
deps = _build_deps(state=state, quality_tier_result=('lossless', 1))
qs.run_quality_scanner('watchlist', 1, deps)
assert state['quality_met'] == 1
assert state['low_quality'] == 0
def test_low_quality_tracks_attempted(mock_db_and_wishlist):
"""Low-quality tracks (tier_num > min) trigger Spotify search."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('Artist')]
db._tracks = [_track_row(file_path='/x.mp3', artist_name='Artist', title='Track')]
state = {}
match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
deps = _build_deps(state=state, quality_tier_result=('low_lossy', 4),
spotify_results=[match])
qs.run_quality_scanner('watchlist', 1, deps)
assert state['low_quality'] == 1
assert deps.spotify_client.search_calls # spotify queried
# ---------------------------------------------------------------------------
# Match → wishlist add
# ---------------------------------------------------------------------------
def test_match_adds_to_wishlist(mock_db_and_wishlist):
"""High-confidence match → wishlist_service.add_spotify_track_to_wishlist called."""
db, ws = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('Artist')]
db._tracks = [_track_row(artist_name='Artist', title='Track', file_path='/x.mp3', bitrate=128)]
state = {}
match = _FakeSpotifyTrack(name='Track', artists=['Artist'])
deps = _build_deps(state=state, quality_tier_result=('low_lossy', 4),
spotify_results=[match])
qs.run_quality_scanner('watchlist', 1, deps)
assert state['matched'] == 1
assert len(ws.added) == 1
add_args = ws.added[0]
assert add_args['source_type'] == 'quality_scanner'
assert add_args['source_context']['original_file_path'] == '/x.mp3'
def test_no_match_no_wishlist_add(mock_db_and_wishlist):
"""No match found → no wishlist add, matched stays 0."""
db, ws = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row(artist_name='A', title='Z', file_path='/x.mp3')]
state = {}
# No spotify results → no match
deps = _build_deps(state=state, quality_tier_result=('low_lossy', 4),
spotify_results=[])
qs.run_quality_scanner('watchlist', 1, deps)
assert state['matched'] == 0
assert ws.added == []
# ---------------------------------------------------------------------------
# Stop request gate
# ---------------------------------------------------------------------------
def test_stop_request_halts_loop(mock_db_and_wishlist):
"""Setting state['status'] != 'running' mid-loop halts processing."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row(track_id=1), _track_row(track_id=2)]
state = {}
deps = _build_deps(state=state, quality_tier_result=('lossless', 1))
# Override get_quality_tier_from_extension to set stop after first track
call_count = [0]
def stop_after_first(fp):
call_count[0] += 1
if call_count[0] == 1:
# Set status to non-running BEFORE second track iter checks
with deps.quality_scanner_lock:
state['status'] = 'stopping'
return ('lossless', 1)
deps.get_quality_tier_from_extension = stop_after_first
qs.run_quality_scanner('watchlist', 1, deps)
# Only first track processed
assert state['quality_met'] == 1
# ---------------------------------------------------------------------------
# Completion
# ---------------------------------------------------------------------------
def test_completion_marks_finished(mock_db_and_wishlist):
"""All tracks processed → status='finished', progress=100."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row()]
state = {}
deps = _build_deps(state=state)
qs.run_quality_scanner('watchlist', 1, deps)
assert state['status'] == 'finished'
assert state['progress'] == 100
def test_automation_event_emitted(mock_db_and_wishlist):
"""Successful completion emits 'quality_scan_completed' on automation engine."""
db, _ = mock_db_and_wishlist
db._watchlist_artists = [_WatchlistArtist('A')]
db._tracks = [_track_row()]
automation = _FakeAutomationEngine()
state = {}
deps = _build_deps(state=state, automation=automation)
qs.run_quality_scanner('watchlist', 1, deps)
assert any(name == 'quality_scan_completed' for name, _ in automation.events)
# ---------------------------------------------------------------------------
# All-library scope
# ---------------------------------------------------------------------------
def test_scope_all_loads_all_tracks(mock_db_and_wishlist):
"""scope != 'watchlist' loads all tracks (no watchlist filter)."""
db, _ = mock_db_and_wishlist
db._tracks = [_track_row(track_id=1), _track_row(track_id=2)]
state = {}
deps = _build_deps(state=state)
qs.run_quality_scanner('all', 1, deps)
assert state['total'] == 2