diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py index fa95fc70..8700723c 100644 --- a/core/repair_jobs/__init__.py +++ b/core/repair_jobs/__init__.py @@ -40,6 +40,7 @@ _JOB_MODULES = [ 'core.repair_jobs.metadata_gap_filler', 'core.repair_jobs.album_completeness', 'core.repair_jobs.fake_lossless_detector', + 'core.repair_jobs.quality_upgrade_scanner', 'core.repair_jobs.library_reorganize', 'core.repair_jobs.mbid_mismatch_detector', 'core.repair_jobs.single_album_dedup', diff --git a/core/repair_jobs/quality_upgrade_scanner.py b/core/repair_jobs/quality_upgrade_scanner.py new file mode 100644 index 00000000..9919ae1c --- /dev/null +++ b/core/repair_jobs/quality_upgrade_scanner.py @@ -0,0 +1,252 @@ +"""Quality Upgrade Scanner Job — flags library tracks below the user's profile. + +Scans the whole library (not just Transfer) by resolving each track's DB file +path to a real file on disk, probing its ACTUAL measured audio quality (bit +depth / sample rate / bitrate via mutagen — the SAME `probe_audio_quality` the +download import guard uses), and checking it against the user's v3 ranked +targets with `quality_meets_profile` (strict; fallback ignored — that's a +download-time concession, not a definition of "good enough"). + +Every track that satisfies none of the targets becomes a finding the user can: + - 'redownload': add the track to the wishlist and delete the low-quality file + - 'delete': remove the low-quality file and its DB record + - 'ignore': dismiss the finding (handled in the UI via the dismiss endpoint) + +This unifies the library quality check onto the exact same pipeline as the +download quality gate — no more extension-only tier guessing. +""" + +import os + +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_job.quality_upgrade") + + +@register_job +class QualityUpgradeScannerJob(RepairJob): + job_id = 'quality_upgrade_scanner' + display_name = 'Quality Upgrade Scanner' + description = 'Flags library tracks below your quality profile' + help_text = ( + 'Scans your music library and checks every track\'s REAL audio quality ' + '(bit depth, sample rate, bitrate — read from the file itself, not just ' + 'the extension) against your configured quality profile. This is the same ' + 'check the download pipeline runs, so a track flagged here is one the ' + 'downloader would also reject.\n\n' + 'Each below-profile track is reported as a finding. You can:\n' + '• Re-download — add the track to your wishlist and remove the low-quality file\n' + '• Delete — remove the low-quality file entirely\n' + '• Ignore — dismiss the finding and keep the file\n\n' + 'The scan only reports — it never deletes or re-downloads on its own. ' + 'Profile targets and fallback come straight from Settings → Quality, so ' + 'adjusting your profile changes what counts as "below quality" here.' + ) + icon = 'repair-icon-lossless' + default_enabled = False + default_interval_hours = 168 + default_settings = {} + auto_fix = False # User chooses fix action per finding + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + # Load the user's v3 ranked targets — the SAME definition the download + # import guard uses. Strict: a track is below-profile when its measured + # quality satisfies NONE of the targets (fallback is not consulted). + from core.quality.selection import targets_from_profile, quality_meets_profile + try: + profile = context.db.get_quality_profile() + except Exception as e: + logger.warning("Could not load quality profile: %s", e) + return result + targets, _fallback = targets_from_profile(profile) + if not targets: + logger.info("Quality profile has no targets — nothing to check against") + return result + + logger.info("Quality upgrade scan — profile targets (strict): %s", + [t.label for t in targets]) + + from core.imports.file_ops import probe_audio_quality + + db_tracks = self._load_db_tracks(context) + if not db_tracks: + logger.info("No library tracks with file paths found") + return result + + track_list = sorted(db_tracks.items(), key=lambda x: str(x[0])) + total = len(track_list) + if context.report_progress: + context.report_progress(phase=f'Scanning {total} library tracks...', total=total) + if context.update_progress: + context.update_progress(0, total) + + probe_failed = 0 + for i, (track_id, info) in enumerate(track_list): + if context.check_stop(): + return result + if i % 20 == 0 and context.wait_if_paused(): + return result + + resolved = self._resolve_path(info.get('file_path', ''), context) + if not resolved: + result.skipped += 1 + probe_failed += 1 + continue + + result.scanned += 1 + fname = os.path.basename(resolved) + if context.report_progress and i % 25 == 0: + context.report_progress( + scanned=i + 1, total=total, + phase=f'Checking {i + 1} / {total}', + log_line=f'Checking: {fname}', + log_type='info', + ) + + try: + aq = probe_audio_quality(resolved) + except Exception as e: + logger.debug("Probe failed for %s: %s", fname, e) + aq = None + + if aq is None: + # File couldn't be read — can't judge quality, leave unflagged. + probe_failed += 1 + result.skipped += 1 + continue + + # Strict profile check — identical to the download guard. + if quality_meets_profile(aq, targets): + if context.update_progress and (i + 1) % 25 == 0: + context.update_progress(i + 1, total) + continue + + # Below profile → create a finding. + current_label = aq.label() + target_labels = [t.label for t in targets] + if context.report_progress: + context.report_progress( + log_line=f'Below quality: {fname} — {current_label}', + log_type='error', + ) + if context.create_finding: + inserted = context.create_finding( + job_id=self.job_id, + finding_type='quality_upgrade', + severity='info', + entity_type='track', + entity_id=str(track_id), + file_path=resolved, + title=f'Below quality: {info.get("title") or fname} ({current_label})', + description=( + f'"{info.get("title") or fname}" by ' + f'{info.get("artist") or "Unknown"} is {current_label}, ' + f'which does not meet your quality profile ' + f'({", ".join(target_labels[:3])}' + f'{"…" if len(target_labels) > 3 else ""}).' + ), + details={ + 'current_quality': current_label, + 'current_format': aq.format, + 'current_bitrate': aq.bitrate, + 'current_sample_rate': aq.sample_rate, + 'current_bit_depth': aq.bit_depth, + 'target_qualities': target_labels, + 'expected_title': info.get('title', ''), + 'expected_artist': info.get('artist', ''), + 'album_title': info.get('album_title', ''), + 'track_number': info.get('track_number'), + 'album_thumb_url': info.get('album_thumb_url'), + 'artist_thumb_url': info.get('artist_thumb_url'), + }, + ) + if inserted: + result.findings_created += 1 + else: + result.findings_skipped_dedup += 1 + + if context.update_progress and (i + 1) % 25 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + + if probe_failed: + logger.warning( + "Quality upgrade scan: %d/%d tracks could not be read/resolved and " + "were left unflagged (their quality couldn't be verified).", + probe_failed, total) + logger.info("Quality upgrade scan: %d scanned, %d below profile, %d skipped", + result.scanned, result.findings_created, result.skipped) + return result + + def _load_db_tracks(self, context: JobContext) -> dict: + """Load all library tracks keyed by ID (mirrors AcoustIDScannerJob).""" + tracks = {} + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT t.id, t.title, + COALESCE(NULLIF(t.track_artist, ''), ar.name) AS artist, + t.file_path, t.track_number, + al.title AS album_title, al.thumb_url, ar.thumb_url + FROM tracks t + LEFT JOIN artists ar ON ar.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.file_path IS NOT NULL AND t.file_path != '' + """) + for row in cursor.fetchall(): + track_id = row[0] + if track_id is None: + continue + tracks[str(track_id)] = { + 'title': row[1] or '', + 'artist': row[2] or '', + 'file_path': row[3] or '', + 'track_number': row[4], + 'album_title': row[5] or '', + 'album_thumb_url': row[6] or None, + 'artist_thumb_url': row[7] or None, + } + except Exception as e: + logger.error("Error loading tracks from DB: %s", e) + finally: + if conn: + conn.close() + return tracks + + def _resolve_path(self, file_path, context): + """Resolve a DB file path to an actual file on disk via the shared + resolver (picks up library.music_paths + Plex locations too).""" + if not file_path: + return None + if os.path.exists(file_path): + return file_path + from core.library.path_resolver import resolve_library_file_path + return resolve_library_file_path( + file_path, + transfer_folder=context.transfer_folder, + config_manager=context.config_manager, + ) + + def estimate_scope(self, context: JobContext) -> int: + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT COUNT(*) FROM tracks + WHERE file_path IS NOT NULL AND file_path != '' + """) + return cursor.fetchone()[0] + except Exception: + return 0 + finally: + if conn: + conn.close() diff --git a/core/repair_worker.py b/core/repair_worker.py index f57cba5d..4a0bdd9a 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -979,6 +979,7 @@ class RepairWorker: 'unwanted_content': self._fix_unwanted_content, 'unknown_artist': self._fix_unknown_artist, 'acoustid_mismatch': self._fix_acoustid_mismatch, + 'quality_upgrade': self._fix_quality_upgrade, 'missing_discography_track': self._fix_discography_backfill, 'library_retag': self._fix_library_retag, } @@ -2153,6 +2154,83 @@ class RepairWorker: return {'success': True, 'action': 'retagged', '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): """Remove the mismatched MusicBrainz recording ID from the audio file.""" if not file_path: @@ -3337,7 +3415,8 @@ class RepairWorker: 'album_tag_inconsistency', 'incomplete_album', 'path_mismatch', 'missing_lossy_copy', 'missing_replaygain', 'empty_folder', - 'missing_discography_track', 'acoustid_mismatch') + 'missing_discography_track', 'acoustid_mismatch', + 'quality_upgrade') placeholders = ','.join(['?'] * len(fixable_types)) where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"] params = list(fixable_types) diff --git a/webui/static/enrichment.js b/webui/static/enrichment.js index 3475b092..9c4f825a 100644 --- a/webui/static/enrichment.js +++ b/webui/static/enrichment.js @@ -2736,7 +2736,8 @@ async function loadRepairFindings() { missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number', missing_lyrics: 'Missing Lyrics', expired_download: 'Expired', missing_replaygain: 'No ReplayGain', empty_folder: 'Empty Folder', - missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag' + missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag', + quality_upgrade: 'Low Quality' }; // Finding types that have an automated fix action @@ -2754,6 +2755,7 @@ async function loadRepairFindings() { incomplete_album: 'Auto-Fill', missing_lossy_copy: 'Convert', acoustid_mismatch: 'Fix', + quality_upgrade: 'Upgrade', missing_discography_track: 'Add to Wishlist', library_retag: 'Apply Tags', }; @@ -3468,6 +3470,15 @@ async function fixRepairFinding(id, findingType) { fixAction = await _promptAcoustidAction(); if (!fixAction) return; } + // Quality upgrade: redownload, delete, or ignore (dismiss) + if (findingType === 'quality_upgrade') { + fixAction = await _promptQualityUpgradeAction(); + if (!fixAction) return; // cancelled + if (fixAction === 'ignore') { + await dismissRepairFinding(id); + return; + } + } // Discography backfill: add to wishlist or just clear the finding if (findingType === 'missing_discography_track') { const choice = await _promptDiscographyBackfillAction(1); @@ -3625,6 +3636,46 @@ function _promptAcoustidAction() { }); } +function _promptQualityUpgradeAction() { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; + overlay.innerHTML = ` +
+
Low-Quality Track
+
+ This file is below your quality profile. Choose what to do. +
+
+ + + +
+
+ Re-download = add to wishlist for a better-quality copy & delete this file • Delete = remove file and DB entry • Ignore = keep the file and dismiss this finding +
+ +
+ `; + document.body.appendChild(overlay); + + overlay.querySelector('#_qual-redownload').onclick = () => { overlay.remove(); resolve('redownload'); }; + overlay.querySelector('#_qual-delete').onclick = () => { overlay.remove(); resolve('delete'); }; + overlay.querySelector('#_qual-ignore').onclick = () => { overlay.remove(); resolve('ignore'); }; + overlay.querySelector('#_qual-cancel').onclick = () => { overlay.remove(); resolve(null); }; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + }); +} + function _promptDiscographyBackfillAction(count = 1) { const isSingle = count <= 1; const headerText = isSingle ? 'Missing Discography Track' : `Missing Discography Tracks (${count})`; @@ -3762,6 +3813,17 @@ async function bulkFixFindings() { if (!backfillAction) return; } + // If any selected findings are quality upgrades, prompt once (redownload/delete/ignore) + const selectedQualityCards = ids.filter(id => { + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + return card && card.dataset.jobId === 'quality_upgrade_scanner'; + }); + let qualityFixAction = null; + if (selectedQualityCards.length > 0) { + qualityFixAction = await _promptQualityUpgradeAction(); + if (!qualityFixAction) return; + } + let fixed = 0, failed = 0, lastError = ''; showToast(`Fixing ${ids.length} findings...`, 'info'); @@ -3773,6 +3835,7 @@ async function bulkFixFindings() { const isDead = card && card.dataset.jobId === 'dead_file_cleaner'; const isAcoustid = card && card.dataset.jobId === 'acoustid_scanner'; const isBackfill = card && card.dataset.jobId === 'discography_backfill'; + const isQuality = card && card.dataset.jobId === 'quality_upgrade_scanner'; // Discography backfill "Just Clear" path uses the dismiss endpoint, // not the fix endpoint — so handle it inline before the fix call. @@ -3787,10 +3850,23 @@ async function bulkFixFindings() { continue; } + // Quality "Ignore" likewise dismisses rather than fixing. + if (isQuality && qualityFixAction === 'ignore') { + try { + const resp = await fetch(`/api/repair/findings/${id}/dismiss`, { method: 'POST' }); + if (resp.ok) fixed++; + else { failed++; lastError = 'dismiss failed'; } + } catch { + failed++; + } + continue; + } + let body = {}; if (isOrphan && orphanFixAction) body = { fix_action: orphanFixAction }; else if (isDead && deadFixAction) body = { fix_action: deadFixAction }; else if (isAcoustid && acoustidFixAction) body = { fix_action: acoustidFixAction }; + else if (isQuality && qualityFixAction) body = { fix_action: qualityFixAction }; // Discography backfill "Add to Wishlist" falls through with empty body // — the fix handler already adds to wishlist by default. diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index 9cfed00f..97c04176 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -2819,46 +2819,33 @@ 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; + // The quality check now runs as a proper Library Maintenance job: it probes + // each library file's REAL audio quality (same pipeline as the download + // gate) and reports below-profile tracks as FINDINGS you can re-download / + // delete / ignore — instead of silently adding to the wishlist. Triggering + // here just kicks off a "Run Now" of that job; results land under + // Library Maintenance → Findings. + try { + button.disabled = true; + button.textContent = 'Starting...'; + const response = await fetch('/api/repair/jobs/quality_upgrade_scanner/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); - 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'); + if (response.ok) { + showToast('Quality scan started — below-quality tracks will appear under Library Maintenance → Findings.', 'success'); + } else { + let msg = 'Error starting quality scan'; + try { msg = (await response.json()).error || msg; } catch {} + showToast(msg, 'error'); } + } catch (error) { + showToast('Failed to start quality scan.', 'error'); + } finally { + button.disabled = false; + button.textContent = 'Scan Library'; } }