fix(repair): merge duplicate _fix_quality_upgrade + update tests (#896)
repair_worker had TWO _fix_quality_upgrade methods; the legacy one (expecting expected_title/_fix_action) shadowed the new findings-based one (matched_track_ data), so applying a Quality Upgrade finding failed live with "No title/artist". The "remove dead functions" refactor missed it. Merge into a single handler: matched_track_data -> wishlist (safe pattern, no auto-delete on redownload) plus _fix_action='delete' -> remove file + row. Also drops the duplicate dispatch key. test_quality_upgrade: the pure-decision tests called deleted helpers (meets_preferred_quality / classify_track_quality / preferred_quality_floor / RANK_*). Rewire them to the shared v3 API (targets_from_profile + quality_meets_profile); drop the few that only pinned deleted internals; update the scan stubs for the new resolve_library_file_path/_read_file_ids signatures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d310b090e2
commit
2ae9ad3c6f
2 changed files with 65 additions and 113 deletions
|
|
@ -997,7 +997,6 @@ class RepairWorker:
|
||||||
'quality_upgrade': self._fix_quality_upgrade,
|
'quality_upgrade': self._fix_quality_upgrade,
|
||||||
'missing_discography_track': self._fix_discography_backfill,
|
'missing_discography_track': self._fix_discography_backfill,
|
||||||
'library_retag': self._fix_library_retag,
|
'library_retag': self._fix_library_retag,
|
||||||
'quality_upgrade': self._fix_quality_upgrade,
|
|
||||||
}
|
}
|
||||||
handler = handlers.get(finding_type)
|
handler = handlers.get(finding_type)
|
||||||
if not handler:
|
if not handler:
|
||||||
|
|
@ -1025,9 +1024,43 @@ class RepairWorker:
|
||||||
return {'success': False, 'error': str(e)}
|
return {'success': False, 'error': str(e)}
|
||||||
|
|
||||||
def _fix_quality_upgrade(self, entity_type, entity_id, file_path, details):
|
def _fix_quality_upgrade(self, entity_type, entity_id, file_path, details):
|
||||||
"""Add the matched higher-quality version to the wishlist (with album
|
"""Apply a Quality Upgrade finding (user-approved; the old Quality
|
||||||
context). Applying a Quality Upgrade finding is the user-approved step
|
Scanner did this without review). Action via ``details['_fix_action']``:
|
||||||
that the old auto-acting Quality Scanner did without review."""
|
|
||||||
|
'redownload' (default): add the matched higher-quality version to the
|
||||||
|
wishlist (with album context) for a profile-gated re-download.
|
||||||
|
The low-quality file stays in place — it's replaced only after the
|
||||||
|
better version actually imports (safe pattern; auto-delete-on-
|
||||||
|
import is handled separately).
|
||||||
|
'delete': remove the low-quality file + its DB row outright.
|
||||||
|
'ignore' is handled in the UI by dismissing the finding — never here.
|
||||||
|
"""
|
||||||
|
fix_action = details.get('_fix_action', 'redownload')
|
||||||
|
|
||||||
|
if fix_action == 'delete':
|
||||||
|
if file_path:
|
||||||
|
resolved = _resolve_file_path(
|
||||||
|
file_path, self.transfer_folder,
|
||||||
|
config_manager=self._config_manager)
|
||||||
|
if resolved and os.path.exists(resolved):
|
||||||
|
try:
|
||||||
|
os.remove(resolved)
|
||||||
|
self._cleanup_empty_parents(resolved)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not delete low-quality file %s: %s",
|
||||||
|
resolved, e)
|
||||||
|
if entity_id:
|
||||||
|
try:
|
||||||
|
conn = self.db._get_connection()
|
||||||
|
conn.cursor().execute("DELETE FROM tracks WHERE id = ?", (entity_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
return {'success': False, 'error': f'DB delete failed: {e}'}
|
||||||
|
return {'success': True, 'action': 'deleted_file',
|
||||||
|
'message': f'Deleted low-quality file: '
|
||||||
|
f'{os.path.basename(file_path or "")}'}
|
||||||
|
|
||||||
track_data = details.get('matched_track_data')
|
track_data = details.get('matched_track_data')
|
||||||
if not track_data:
|
if not track_data:
|
||||||
return {'success': False, 'error': 'No matched track in finding'}
|
return {'success': False, 'error': 'No matched track in finding'}
|
||||||
|
|
@ -2201,83 +2234,6 @@ class RepairWorker:
|
||||||
return {'success': True, 'action': 'retagged',
|
return {'success': True, 'action': 'retagged',
|
||||||
'message': f'Updated to: "{aid_title}" by {aid_artist}'}
|
'message': f'Updated to: "{aid_title}" by {aid_artist}'}
|
||||||
|
|
||||||
def _fix_quality_upgrade(self, entity_type, entity_id, file_path, details):
|
|
||||||
"""Fix a below-quality library track. Actions (via details['_fix_action']):
|
|
||||||
'redownload' (default): add the track to the wishlist for a better-
|
|
||||||
quality re-download, then delete the low-quality file + DB row.
|
|
||||||
'delete': just remove the low-quality file and its DB record.
|
|
||||||
'ignore' is handled in the UI by dismissing the finding — it never
|
|
||||||
reaches here.
|
|
||||||
"""
|
|
||||||
fix_action = details.get('_fix_action', 'redownload')
|
|
||||||
track_id = entity_id
|
|
||||||
|
|
||||||
def _delete_file_and_row():
|
|
||||||
if file_path:
|
|
||||||
resolved = _resolve_file_path(
|
|
||||||
file_path, self.transfer_folder,
|
|
||||||
config_manager=self._config_manager)
|
|
||||||
if resolved and os.path.exists(resolved):
|
|
||||||
try:
|
|
||||||
os.remove(resolved)
|
|
||||||
self._cleanup_empty_parents(resolved)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not delete low-quality file %s: %s",
|
|
||||||
resolved, e)
|
|
||||||
if track_id:
|
|
||||||
try:
|
|
||||||
conn = self.db._get_connection()
|
|
||||||
conn.cursor().execute("DELETE FROM tracks WHERE id = ?", (track_id,))
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
except Exception as e:
|
|
||||||
return f'DB delete failed: {e}'
|
|
||||||
return None
|
|
||||||
|
|
||||||
if fix_action == 'delete':
|
|
||||||
err = _delete_file_and_row()
|
|
||||||
if err:
|
|
||||||
return {'success': False, 'error': err}
|
|
||||||
return {'success': True, 'action': 'deleted_file',
|
|
||||||
'message': f'Deleted low-quality file: '
|
|
||||||
f'{os.path.basename(file_path or "")}'}
|
|
||||||
|
|
||||||
if fix_action == 'redownload':
|
|
||||||
# Add the (correct) track to the wishlist for a higher-quality
|
|
||||||
# re-download, then remove the low-quality copy — mirrors the
|
|
||||||
# acoustid 'redownload' path so it flows through the same wishlist
|
|
||||||
# → download pipeline (which applies the quality gate on the way in).
|
|
||||||
expected_title = details.get('expected_title', '')
|
|
||||||
expected_artist = details.get('expected_artist', '')
|
|
||||||
album_title = details.get('album_title', '')
|
|
||||||
if expected_title and expected_artist:
|
|
||||||
try:
|
|
||||||
track_data = {
|
|
||||||
'id': f'quality_upgrade_{uuid.uuid4().hex[:8]}',
|
|
||||||
'name': expected_title,
|
|
||||||
'artists': [{'name': expected_artist}],
|
|
||||||
'album': {'name': album_title} if album_title else {'name': expected_title},
|
|
||||||
}
|
|
||||||
self.db.add_to_wishlist(
|
|
||||||
spotify_track_data=track_data,
|
|
||||||
failure_reason='Quality upgrade — re-downloading at higher quality',
|
|
||||||
source_type='repair',
|
|
||||||
)
|
|
||||||
logger.info("Added '%s' by '%s' to wishlist for quality upgrade",
|
|
||||||
expected_title, expected_artist)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Could not add to wishlist: %s", e)
|
|
||||||
else:
|
|
||||||
return {'success': False,
|
|
||||||
'error': 'No title/artist available to re-download'}
|
|
||||||
err = _delete_file_and_row()
|
|
||||||
if err:
|
|
||||||
logger.debug("quality_upgrade redownload: %s", err)
|
|
||||||
return {'success': True, 'action': 'redownload',
|
|
||||||
'message': f'Added "{expected_title}" to wishlist, removed low-quality file'}
|
|
||||||
|
|
||||||
return {'success': False, 'error': f'Unknown fix action: {fix_action}'}
|
|
||||||
|
|
||||||
def _fix_mbid_mismatch(self, entity_type, entity_id, file_path, details):
|
def _fix_mbid_mismatch(self, entity_type, entity_id, file_path, details):
|
||||||
"""Remove the mismatched MusicBrainz recording ID from the audio file."""
|
"""Remove the mismatched MusicBrainz recording ID from the audio file."""
|
||||||
if not file_path:
|
if not file_path:
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,25 @@ NOTHING_ENABLED = {'qualities': {'flac': {'enabled': False}, 'mp3_320': {'enable
|
||||||
|
|
||||||
|
|
||||||
# --- pure quality decision -------------------------------------------------
|
# --- pure quality decision -------------------------------------------------
|
||||||
|
#
|
||||||
|
# The old extension-only classifier (meets_preferred_quality / classify_track_
|
||||||
|
# quality / preferred_quality_floor / RANK_*) was deleted in favour of the
|
||||||
|
# shared v3 path: probe → AudioQuality → quality_meets_profile against the
|
||||||
|
# profile's ranked targets. These pin the same behavioural contract through the
|
||||||
|
# new API. (Bps→kbps normalisation now lives in probe_audio_quality and is
|
||||||
|
# covered by its own tests; the deleted-internals tests for it were removed.)
|
||||||
|
|
||||||
|
def meets(path, bitrate, profile):
|
||||||
|
"""Does a file of this format+bitrate satisfy the profile? Mirrors the
|
||||||
|
scanner's decision: build the measured AudioQuality and check it against the
|
||||||
|
v3 ranked targets derived from the profile (empty targets → nothing flagged)."""
|
||||||
|
from core.quality.model import AudioQuality
|
||||||
|
from core.quality.selection import quality_meets_profile, targets_from_profile
|
||||||
|
|
||||||
|
ext = path.rsplit('.', 1)[-1].lower()
|
||||||
|
targets, _ = targets_from_profile(profile)
|
||||||
|
return quality_meets_profile(AudioQuality(format=ext, bitrate=bitrate), targets)
|
||||||
|
|
||||||
|
|
||||||
def test_balanced_profile_accepts_320_mp3_REGRESSION():
|
def test_balanced_profile_accepts_320_mp3_REGRESSION():
|
||||||
"""The headline bug: with FLAC+320+256 enabled, a 320 kbps MP3 is acceptable.
|
"""The headline bug: with FLAC+320+256 enabled, a 320 kbps MP3 is acceptable.
|
||||||
|
|
@ -69,30 +88,6 @@ def test_nothing_enabled_flags_nothing():
|
||||||
assert meets('song.mp3', 64, NOTHING_ENABLED) is True
|
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) ----------------------------------------
|
# --- scan produces a finding (seam) ----------------------------------------
|
||||||
|
|
||||||
class _FakeConn:
|
class _FakeConn:
|
||||||
|
|
@ -156,9 +151,10 @@ def _stub_quality(monkeypatch, *, meets: bool):
|
||||||
ranked targets. Tests use fake paths, so we resolve the path to itself,
|
ranked targets. Tests use fake paths, so we resolve the path to itself,
|
||||||
return a dummy measured quality, and force the meets/below verdict.
|
return a dummy measured quality, and force the meets/below verdict.
|
||||||
"""
|
"""
|
||||||
from core.quality.model import AudioQuality
|
from core.quality.model import AudioQuality, QualityTarget
|
||||||
monkeypatch.setattr(qu, 'targets_from_profile', lambda profile: (['target'], False))
|
monkeypatch.setattr(qu, 'targets_from_profile',
|
||||||
monkeypatch.setattr(qu, 'resolve_library_file_path', lambda p: p)
|
lambda profile: ([QualityTarget(label='MP3 320', format='mp3', min_bitrate=320)], False))
|
||||||
|
monkeypatch.setattr(qu, 'resolve_library_file_path', lambda p, **kw: p)
|
||||||
monkeypatch.setattr(qu, 'probe_audio_quality',
|
monkeypatch.setattr(qu, 'probe_audio_quality',
|
||||||
lambda p: AudioQuality(format='mp3', bitrate=128))
|
lambda p: AudioQuality(format='mp3', bitrate=128))
|
||||||
monkeypatch.setattr(qu, 'quality_meets_profile', lambda aq, targets: meets)
|
monkeypatch.setattr(qu, 'quality_meets_profile', lambda aq, targets: meets)
|
||||||
|
|
@ -185,7 +181,7 @@ def test_scan_creates_finding_for_low_quality_track(monkeypatch):
|
||||||
fake_match = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'],
|
fake_match = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'],
|
||||||
'album': {'name': 'Album X', 'images': []}}
|
'album': {'name': 'Album X', 'images': []}}
|
||||||
# No track-id / ISRC / album hit → exercise the search tier.
|
# No track-id / ISRC / album hit → exercise the search tier.
|
||||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {})
|
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {})
|
||||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
||||||
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
|
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
|
||||||
monkeypatch.setattr(qu, '_find_best_match',
|
monkeypatch.setattr(qu, '_find_best_match',
|
||||||
|
|
@ -229,7 +225,7 @@ def test_scan_prefers_track_id_tier(monkeypatch):
|
||||||
"""The source's own track ID (from file tags) wins over every other tier."""
|
"""The source's own track ID (from file tags) wins over every other tier."""
|
||||||
db = _FakeDB([_row()], BALANCED)
|
db = _FakeDB([_row()], BALANCED)
|
||||||
_stub_engine(monkeypatch)
|
_stub_engine(monkeypatch)
|
||||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'spotify_track_id': 'sp9', 'isrc': 'X'})
|
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {'spotify_track_id': 'sp9', 'isrc': 'X'})
|
||||||
fake = {'id': 'sp9', 'name': 'Song One', 'album': {'name': 'Album X'}}
|
fake = {'id': 'sp9', 'name': 'Song One', 'album': {'name': 'Album X'}}
|
||||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda ids, sp: (fake, 'spotify'))
|
monkeypatch.setattr(qu, '_match_via_track_id', lambda ids, sp: (fake, 'spotify'))
|
||||||
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
|
monkeypatch.setattr(qu, '_normalize_track_match', lambda t, s: dict(fake))
|
||||||
|
|
@ -292,7 +288,7 @@ def test_scan_prefers_isrc_exact_match_over_fuzzy(monkeypatch):
|
||||||
and do NOT run the album/search tiers."""
|
and do NOT run the album/search tiers."""
|
||||||
db = _FakeDB([_row()], BALANCED)
|
db = _FakeDB([_row()], BALANCED)
|
||||||
_stub_engine(monkeypatch)
|
_stub_engine(monkeypatch)
|
||||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {'isrc': 'USRC17607839'})
|
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {'isrc': 'USRC17607839'})
|
||||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
||||||
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
|
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
|
||||||
monkeypatch.setattr(qu, '_match_via_isrc', lambda isrc, sp: (fake, 'spotify'))
|
monkeypatch.setattr(qu, '_match_via_isrc', lambda isrc, sp: (fake, 'spotify'))
|
||||||
|
|
@ -314,7 +310,7 @@ def test_scan_falls_back_to_search_without_ids(monkeypatch):
|
||||||
"""No track-ID / ISRC / album hit → fall back to fuzzy search."""
|
"""No track-ID / ISRC / album hit → fall back to fuzzy search."""
|
||||||
db = _FakeDB([_row()], BALANCED)
|
db = _FakeDB([_row()], BALANCED)
|
||||||
_stub_engine(monkeypatch)
|
_stub_engine(monkeypatch)
|
||||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {}) # un-enriched
|
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {}) # un-enriched
|
||||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
||||||
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
|
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (None, None))
|
||||||
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
|
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
|
||||||
|
|
@ -333,7 +329,7 @@ def test_scan_uses_album_tier_when_no_ids(monkeypatch):
|
||||||
'album', and the fuzzy search is never reached."""
|
'album', and the fuzzy search is never reached."""
|
||||||
db = _FakeDB([_row()], BALANCED)
|
db = _FakeDB([_row()], BALANCED)
|
||||||
_stub_engine(monkeypatch)
|
_stub_engine(monkeypatch)
|
||||||
monkeypatch.setattr(qu, '_read_file_ids', lambda fp: {})
|
monkeypatch.setattr(qu, '_read_file_ids', lambda fp, **kw: {})
|
||||||
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
monkeypatch.setattr(qu, '_match_via_track_id', lambda *a, **k: (None, None))
|
||||||
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
|
fake = {'id': 'sp1', 'name': 'Song One', 'artists': ['Artist A'], 'album': {'name': 'Album X'}}
|
||||||
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (fake, 'spotify'))
|
monkeypatch.setattr(qu, '_match_via_album', lambda *a, **k: (fake, 'spotify'))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue