feat(quality): library quality check as a findings repair job
Replaces the wishlist-only Quality Scanner with a proper Library Maintenance
job that produces actionable findings — same model as the AcoustID/orphan
tools, per user request ("mach ein finding wie jedes anderes Tool").
- New core/repair_jobs/quality_upgrade_scanner.py: iterates DB library
tracks, resolves each path via the shared resolver (now index-0 correct for
relative library paths), probes REAL audio quality with the same
probe_audio_quality the download import guard uses, and checks it against the
user's v3 ranked targets via quality_meets_profile (strict — no extension
guessing, no fallback). Below-profile tracks become 'quality_upgrade'
findings with current vs target quality in details.
- repair_worker._fix_quality_upgrade: redownload (wishlist + delete file/row),
delete (file + row), or ignore (dismiss in UI). Registered in _execute_fix
dispatch + bulk fixable_types.
- Frontend (enrichment.js): 'Low Quality' type label, 'Upgrade' fix button, a
3-way _promptQualityUpgradeAction modal (Re-download / Delete / Ignore),
wired into both single-finding fix and bulk-fix (Ignore → dismiss inline).
- Tools "Quality Scanner" button now triggers Run Now of this job and points
the user to Library Maintenance → Findings.
The old standalone /api/quality-scanner endpoints are left intact (unused by the
button) to avoid churn. Verified: job registers, fix handler dispatches.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4cb1937810
commit
5b3061ee2e
5 changed files with 435 additions and 40 deletions
|
|
@ -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',
|
||||
|
|
|
|||
252
core/repair_jobs/quality_upgrade_scanner.py
Normal file
252
core/repair_jobs/quality_upgrade_scanner.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<div style="background:#1e1e2e;border:1px solid rgba(255,255,255,0.1);border-radius:16px;padding:28px;max-width:460px;width:90%;text-align:center;">
|
||||
<div style="font-size:1.1em;font-weight:600;color:#fff;margin-bottom:8px;">Low-Quality Track</div>
|
||||
<div style="font-size:0.88em;color:rgba(255,255,255,0.6);margin-bottom:20px;">
|
||||
This file is below your quality profile. Choose what to do.
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
|
||||
<button id="_qual-redownload" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(29,185,84,0.4);background:rgba(29,185,84,0.15);color:#1db954;font-weight:600;cursor:pointer;font-family:inherit;">
|
||||
Re-download
|
||||
</button>
|
||||
<button id="_qual-delete" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(239,68,68,0.4);background:rgba(239,68,68,0.1);color:#ef4444;font-weight:500;cursor:pointer;font-family:inherit;">
|
||||
Delete
|
||||
</button>
|
||||
<button id="_qual-ignore" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(255,255,255,0.18);background:rgba(255,255,255,0.05);color:rgba(255,255,255,0.7);font-weight:500;cursor:pointer;font-family:inherit;">
|
||||
Ignore
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-top:12px;font-size:0.78em;color:rgba(255,255,255,0.35);line-height:1.4;">
|
||||
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
|
||||
</div>
|
||||
<button id="_qual-cancel" style="margin-top:12px;padding:6px 16px;border:none;background:none;color:rgba(255,255,255,0.4);cursor:pointer;font-size:0.82em;font-family:inherit;">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue