The old Quality Scanner tool judged quality by file EXTENSION only (a 128k and a 320k MP3 looked identical), ignored the bitrate-based quality profile, used min() of enabled tiers so the default profile flagged the ENTIRE non-lossless library, and auto-dumped every match into the wishlist with no review. This new repair job does it properly: - meets_preferred_quality(): pure, bitrate-AWARE decision honoring every enabled quality bucket (320 MP3 passes a FLAC+320+256 profile; 128 MP3 doesn't). Floor is the worst enabled bucket, not the best. - scans watchlist artists or whole library, finds below-quality tracks, matches a better version at scan time (reusing the existing tested match helpers), emits a FINDING showing the match + confidence. Off by default; nothing auto-queued. - _fix_quality_upgrade apply handler adds the matched track WITH album context to the wishlist — the user-approved version of what the old tool did silently. - Transcode/fake-lossless detection intentionally left to the existing Fake Lossless Detector job. 12 seam tests incl. a regression pinning the default-profile flooding bug. The old tool is still in place; removing it + rewiring its automation action is the next step.
221 lines
7.7 KiB
Python
221 lines
7.7 KiB
Python
"""Quality Upgrade Finder job — the findings-based replacement for the old
|
|
auto-acting Quality Scanner.
|
|
|
|
The old tool judged quality by file EXTENSION only and used min() of the enabled
|
|
tiers, so with the default profile (FLAC + MP3-320 + MP3-256 enabled) it flagged
|
|
EVERY non-lossless file — a 320 kbps MP3 included — and dumped them all into the
|
|
wishlist with no review. These tests pin the corrected behavior: bitrate-aware,
|
|
honors every enabled bucket, and only proposes (findings) rather than auto-acting.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import types
|
|
|
|
import core.repair_jobs.quality_upgrade as qu
|
|
from core.repair_jobs.base import JobContext, JobResult
|
|
|
|
|
|
# Profiles ------------------------------------------------------------------
|
|
|
|
BALANCED = { # default: FLAC + MP3-320 + MP3-256 enabled, MP3-192 off
|
|
'qualities': {
|
|
'flac': {'enabled': True, 'min_kbps': 500},
|
|
'mp3_320': {'enabled': True, 'min_kbps': 280},
|
|
'mp3_256': {'enabled': True, 'min_kbps': 200},
|
|
'mp3_192': {'enabled': False, 'min_kbps': 150},
|
|
}
|
|
}
|
|
LOSSLESS_ONLY = {
|
|
'qualities': {
|
|
'flac': {'enabled': True, 'min_kbps': 500},
|
|
'mp3_320': {'enabled': False, 'min_kbps': 280},
|
|
'mp3_256': {'enabled': False, 'min_kbps': 200},
|
|
'mp3_192': {'enabled': False, 'min_kbps': 150},
|
|
}
|
|
}
|
|
NOTHING_ENABLED = {'qualities': {'flac': {'enabled': False}, 'mp3_320': {'enabled': False}}}
|
|
|
|
|
|
# --- pure quality decision -------------------------------------------------
|
|
|
|
def test_balanced_profile_accepts_320_mp3_REGRESSION():
|
|
"""The headline bug: with FLAC+320+256 enabled, a 320 kbps MP3 is acceptable.
|
|
The old min()-tier logic flagged it (and every other MP3) for re-download."""
|
|
assert meets('song.mp3', 320, BALANCED) is True
|
|
|
|
|
|
def test_balanced_profile_accepts_256_mp3():
|
|
assert meets('song.mp3', 256, BALANCED) is True
|
|
|
|
|
|
def test_balanced_profile_flags_low_bitrate_mp3():
|
|
assert meets('song.mp3', 128, BALANCED) is False
|
|
assert meets('song.mp3', 192, BALANCED) is False # below the 256 floor
|
|
|
|
|
|
def test_flac_always_meets_when_flac_enabled():
|
|
assert meets('song.flac', 900, BALANCED) is True
|
|
assert meets('song.flac', 900, LOSSLESS_ONLY) is True
|
|
|
|
|
|
def test_lossless_only_flags_every_lossy_regardless_of_bitrate():
|
|
assert meets('song.mp3', 320, LOSSLESS_ONLY) is False
|
|
assert meets('song.m4a', 256, LOSSLESS_ONLY) is False
|
|
|
|
|
|
def test_nothing_enabled_flags_nothing():
|
|
"""Empty/disabled profile must NOT flag the whole library."""
|
|
assert meets('song.mp3', 64, NOTHING_ENABLED) is True
|
|
|
|
|
|
def test_bitrate_in_bps_is_normalized():
|
|
"""Library bitrate stored as bps (320000) classifies the same as 320 kbps."""
|
|
assert qu.classify_track_quality('song.mp3', 320000) == qu.RANK_320
|
|
assert meets('song.mp3', 320000, BALANCED) is True
|
|
|
|
|
|
def test_unknown_lossy_bitrate_not_flagged_under_lossy_floor():
|
|
"""A lossy file with no bitrate can't be judged against a lossy floor → don't
|
|
flag (avoid false positives); but under a lossless floor it's clearly below."""
|
|
assert meets('song.mp3', None, BALANCED) is True
|
|
assert meets('song.mp3', None, LOSSLESS_ONLY) is False
|
|
|
|
|
|
def test_floor_is_worst_enabled_not_best():
|
|
# FLAC+320+256 enabled → floor is MP3-256 (rank 2), not FLAC.
|
|
assert qu.preferred_quality_floor(BALANCED) == qu.RANK_256
|
|
assert qu.preferred_quality_floor(LOSSLESS_ONLY) == qu.RANK_LOSSLESS
|
|
assert qu.preferred_quality_floor(NOTHING_ENABLED) is None
|
|
|
|
|
|
def meets(path, bitrate, profile):
|
|
return qu.meets_preferred_quality(path, bitrate, profile)
|
|
|
|
|
|
# --- scan produces a finding (seam) ----------------------------------------
|
|
|
|
class _FakeConn:
|
|
def __init__(self, rows):
|
|
self._rows = rows
|
|
|
|
def execute(self, *a, **k):
|
|
return self
|
|
|
|
def fetchall(self):
|
|
return self._rows
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
class _FakeDB:
|
|
def __init__(self, rows, profile):
|
|
self._rows = rows
|
|
self._profile = profile
|
|
|
|
def get_quality_profile(self):
|
|
return self._profile
|
|
|
|
def _get_connection(self):
|
|
return _FakeConn(self._rows)
|
|
|
|
def get_watchlist_artists(self, profile_id=1):
|
|
return [types.SimpleNamespace(artist_name='Artist A')]
|
|
|
|
|
|
def _ctx(db, findings):
|
|
return JobContext(
|
|
db=db,
|
|
transfer_folder='/tmp',
|
|
config_manager=None,
|
|
create_finding=lambda **kw: findings.append(kw) or True,
|
|
should_stop=lambda: False,
|
|
is_paused=lambda: False,
|
|
)
|
|
|
|
|
|
def test_scan_creates_finding_for_low_quality_track(monkeypatch):
|
|
# One 128 kbps MP3 (below the balanced floor) for Artist A.
|
|
rows = [(1, 'Song One', '/music/a.mp3', 128, 'Artist A', 'Album X', 10)]
|
|
db = _FakeDB(rows, BALANCED)
|
|
|
|
# Stub the metadata side so the test stays offline.
|
|
monkeypatch.setattr(qu, 'get_primary_source', lambda: 'spotify')
|
|
monkeypatch.setattr(qu, 'get_source_priority', lambda src: ['spotify'])
|
|
monkeypatch.setattr(
|
|
'core.matching_engine.MusicMatchingEngine',
|
|
lambda: types.SimpleNamespace(
|
|
generate_download_queries=lambda t: ['q'],
|
|
similarity_score=lambda a, b: 1.0,
|
|
normalize_string=lambda s: s,
|
|
),
|
|
)
|
|
fake_match = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'],
|
|
'album': {'name': 'Album X', 'images': []}}
|
|
monkeypatch.setattr(qu, '_find_best_match',
|
|
lambda *a, **k: (fake_match, 0.95, 'spotify', True))
|
|
monkeypatch.setattr(qu, '_normalize_track_match', lambda track, src: dict(fake_match))
|
|
monkeypatch.setattr(qu, '_track_name', lambda t: 'Song One')
|
|
|
|
findings = []
|
|
job = qu.QualityUpgradeJob()
|
|
# default scope 'watchlist'; config_manager None → defaults used
|
|
result = job.scan(_ctx(db, findings))
|
|
|
|
assert result.findings_created == 1
|
|
assert len(findings) == 1
|
|
f = findings[0]
|
|
assert f['finding_type'] == 'quality_upgrade'
|
|
assert f['entity_id'] == '1'
|
|
# Album context + matched track carried for the apply step.
|
|
assert f['details']['matched_track_data']['id'] == 'sp1'
|
|
assert f['details']['album_title'] == 'Album X'
|
|
assert f['details']['provider'] == 'spotify'
|
|
|
|
|
|
def test_scan_skips_tracks_meeting_quality(monkeypatch):
|
|
# A 320 kbps MP3 meets the balanced profile → no finding, no metadata calls.
|
|
rows = [(2, 'Good Song', '/music/b.mp3', 320, 'Artist A', 'Album Y', 11)]
|
|
db = _FakeDB(rows, BALANCED)
|
|
|
|
def _boom(*a, **k): # must never be called for an acceptable track
|
|
raise AssertionError("matching should not run for an acceptable track")
|
|
|
|
monkeypatch.setattr(qu, '_find_best_match', _boom)
|
|
|
|
findings = []
|
|
result = qu.QualityUpgradeJob().scan(_ctx(db, findings))
|
|
assert result.findings_created == 0
|
|
assert result.skipped == 1
|
|
assert findings == []
|
|
|
|
|
|
# --- fix handler adds to wishlist ------------------------------------------
|
|
|
|
def test_fix_handler_adds_matched_track_to_wishlist():
|
|
from core.repair_worker import RepairWorker
|
|
|
|
captured = {}
|
|
|
|
class _DB:
|
|
def add_to_wishlist(self, **kw):
|
|
captured.update(kw)
|
|
return True
|
|
|
|
worker = object.__new__(RepairWorker)
|
|
worker.db = _DB()
|
|
|
|
details = {
|
|
'matched_track_data': {'id': 'sp1', 'name': 'Song One',
|
|
'album': {'name': 'Album X'}},
|
|
'current_format': 'MP3 192', 'current_bitrate': 192,
|
|
'album_title': 'Album X', 'provider': 'spotify', 'match_confidence': 0.9,
|
|
}
|
|
res = worker._fix_quality_upgrade('track', '1', '/music/a.mp3', details)
|
|
|
|
assert res['success'] is True
|
|
assert captured['spotify_track_data']['id'] == 'sp1'
|
|
assert captured['source_type'] == 'repair'
|
|
assert captured['source_info']['job'] == 'quality_upgrade'
|
|
assert captured['source_info']['album_title'] == 'Album X'
|