Rebuild Discography Backfill: auto-wishlist, Fix All, section UI

Root-cause fix for "scanning 50 artists" then silence: when the master
repair worker was paused, force-run still kicked off _run_job but the
job's first wait_if_paused() blocked forever because is_paused was tied
to the master-enabled state. Force-run now bypasses master-pause —
scheduled runs still respect it.

Also fixes Fix All on discography findings doing nothing: the backend
bulk_fix_findings query had a fixable_types allowlist that excluded
missing_discography_track (and acoustid_mismatch). Added both.

Backfill job rebuild:
- auto_add_to_wishlist opt-in setting — creates findings AND pushes to
  wishlist during the scan
- 3-option fix dialog (Add to Wishlist / Just Clear / Cancel) on single
  Fix, Bulk Fix selection, and Fix All (page-level)
- Fix All "Just Clear" path uses the clear endpoint with job_id filter
  instead of the generic "may delete files" bulk-fix warning
- Batched in-memory matching using get_candidate_albums_for_artist +
  get_candidate_tracks_for_albums (same fast path the Library pages use)
- Rich album context per finding (id, name, album_type, release_date,
  images, artists, total_tracks) — flows through the wishlist pipeline
  so auto-processor classifies each track into the right cycle
  (albums vs singles) and post-processing gets correct folder/tags/art
- Per-artist progress logs [N/50] Scanning ArtistName
- Default interval 24h (was 168h); all release types default on; settings
  reordered with _section_* group headers (Core / Release Types /
  Content Filters)

Repair settings UI:
- Generic _section_<name> key convention renders as an uppercase group
  divider in the settings panel — any job can opt in
- .repair-setting-row gets a dashed bottom border so label↔toggle pairing
  is visually clear
- _prettifyRepairSettingKey fixes acronym capitalization (EPs, not Eps)

Version bumped to 2.36 with changelog entries.
pull/349/head
Broque Thomas 2 months ago
parent 39a07e4bdf
commit 457763cbab

@ -27,28 +27,40 @@ class DiscographyBackfillJob(RepairJob):
description = 'Finds missing albums and tracks for artists in your library'
help_text = (
'Scans each artist in your library, fetches their full discography from '
'the configured metadata source, and adds any tracks you don\'t already '
'own to the wishlist for automatic download.\n\n'
'the configured metadata source, and creates findings for any tracks '
'you don\'t already own. Click Fix on a finding to add it to the '
'wishlist for automatic download.\n\n'
'Respects content filters: live versions, remixes, acoustic versions, '
'instrumentals, and compilations are excluded by default.\n\n'
'Settings:\n'
'- Include Albums/EPs/Singles: Which release types to check\n'
'- Include Live/Remixes/Acoustic/Compilations/Instrumentals: Content type filters\n'
'- Max Artists Per Run: Limit how many artists to process per scan (default: 50)'
'- Max Artists Per Run: Limit how many artists to process per scan (default: 50)\n'
'- Auto Add To Wishlist: When on, missing tracks are pushed to the wishlist during the scan as well as logged as findings\n'
'- Include Albums / EPs / Singles: Which release types to check\n'
'- Include Live / Remixes / Acoustic / Compilations / Instrumentals: Content type filters'
)
icon = 'repair-icon-backfill'
default_enabled = False
default_interval_hours = 168 # Weekly
default_interval_hours = 24 # Daily — the scan is rate-limited at 50 artists per run
# Order matters: the UI renders these in dict-insertion order. Keys beginning
# with `_section_` are rendered as group headers (not settings rows) and are
# stripped from the saved config.
default_settings = {
'_section_core': 'Core',
'max_artists_per_run': 50,
# When on, missing tracks are added to the wishlist during the scan in
# addition to creating findings. When off (default), only findings are
# created; the user reviews them and decides per-track in the repair UI.
'auto_add_to_wishlist': False,
'_section_release_types': 'Release Types',
'include_albums': True,
'include_eps': True,
'include_singles': False,
'include_singles': True,
'_section_content_filters': 'Content Filters',
'include_live': False,
'include_remixes': False,
'include_acoustic': False,
'include_compilations': False,
'include_instrumentals': False,
'max_artists_per_run': 50,
}
auto_fix = False
@ -93,12 +105,15 @@ class DiscographyBackfillJob(RepairJob):
log_type='info',
)
logger.info("[%d/%d] Scanning %s", i + 1, total, artist_name)
try:
missing_count = self._scan_artist(context, artist, settings, primary_source, result)
if missing_count > 0:
logger.info("Found %d missing tracks for %s", missing_count, artist_name)
logger.info("[%d/%d] Found %d missing tracks for %s", i + 1, total, missing_count, artist_name)
else:
logger.info("[%d/%d] %s — no missing tracks", i + 1, total, artist_name)
except Exception as e:
logger.warning("Error scanning discography for %s: %s", artist_name, e)
logger.warning("[%d/%d] Error scanning discography for %s: %s", i + 1, total, artist_name, e)
result.errors += 1
if context.update_progress and (i + 1) % 3 == 0:
@ -118,11 +133,18 @@ class DiscographyBackfillJob(RepairJob):
return result
def _scan_artist(self, context, artist, settings, primary_source, result):
"""Scan one artist's discography and create findings for missing tracks."""
"""Scan one artist's discography and create findings for missing tracks.
Uses the same batched in-memory matching the Library and Artists pages
use (get_candidate_albums_for_artist + get_candidate_tracks_for_albums)
so one artist with a big library doesn't trigger thousands of per-track
SQL queries.
"""
artist_name = artist['name']
result.scanned += 1
# Build source ID map for more accurate lookups
# Build source ID map for more accurate lookups. Primary fallback
# relies on artist-name search when a source ID is missing.
source_ids = {}
if artist.get('spotify_artist_id'):
source_ids['spotify'] = artist['spotify_artist_id']
@ -154,6 +176,24 @@ class DiscographyBackfillJob(RepairJob):
if context.config_manager:
active_server = context.config_manager.get_active_media_server()
auto_add = settings.get('auto_add_to_wishlist', False)
# Pre-fetch the artist's library albums + tracks ONCE per artist for
# fast in-memory matching (same pattern as the Library/Artists page
# completion check). Avoids thousands of per-track SQL calls.
candidate_tracks = None
try:
cand_albums = context.db.get_candidate_albums_for_artist(
artist_name, server_source=active_server
)
if cand_albums:
candidate_tracks = context.db.get_candidate_tracks_for_albums(
[a.id for a in cand_albums]
)
except Exception as exc:
logger.debug("Could not pre-fetch candidates for %s: %s", artist_name, exc)
candidate_tracks = None
# Process albums and singles
for release in albums + singles:
if context.check_stop():
@ -163,7 +203,8 @@ class DiscographyBackfillJob(RepairJob):
release_id = release.get('id', '')
total_tracks = release.get('total_tracks', 0) or 0
album_type = release.get('album_type', 'album')
release_image = release.get('image_url', '')
release_image = release.get('image_url', '') or ''
release_date = release.get('release_date', '') or ''
# Filter by release type
if not self._should_include_release(total_tracks, album_type, settings):
@ -193,6 +234,20 @@ class DiscographyBackfillJob(RepairJob):
if not items:
continue
# Build the full album context once per release so every finding
# created for this release carries the same wishlist-ready dict.
# Matches the shape add_to_wishlist / download pipeline expects.
album_context = {
'id': str(release_id),
'name': release_name,
'album_type': album_type,
'release_date': release_date,
'images': [{'url': release_image}] if release_image else [],
'image_url': release_image,
'artists': [{'name': artist_name}],
'total_tracks': total_tracks,
}
for track_item in items:
if context.check_stop():
return missing_count
@ -201,7 +256,7 @@ class DiscographyBackfillJob(RepairJob):
if not track_name:
continue
# Extract artist name from track
# Extract artist name from track (fall back to the discography artist)
track_artists = track_item.get('artists', [])
if track_artists:
first_artist = track_artists[0]
@ -226,12 +281,15 @@ class DiscographyBackfillJob(RepairJob):
if is_instrumental_version(track_name, release_name):
continue
# Check if track already exists in library
# Check if track already exists in library — batched in-memory
# match when candidates were pre-fetched (fast path). Falls back
# to the legacy SQL path if pre-fetch failed.
db_track, confidence = context.db.check_track_exists(
track_name, track_artist,
confidence_threshold=0.7,
server_source=active_server,
album=release_name,
candidate_tracks=candidate_tracks,
)
if db_track and confidence >= 0.7:
continue # Already owned
@ -244,21 +302,19 @@ class DiscographyBackfillJob(RepairJob):
except Exception:
pass
# Build track data for wishlist
# Build wishlist-ready track data. album is a dict (required by
# add_to_wishlist and by the download pipeline's cover-art
# extraction). Every finding carries enough context that the
# fix handler can hand it straight to the wishlist.
track_data = {
'id': track_item.get('id', f'backfill_{hash(f"{track_artist}_{track_name}") % 100000}'),
'name': track_name,
'artists': [{'name': track_artist}],
'album': {
'name': release_name,
'id': str(release_id),
'images': [{'url': release_image}] if release_image else [],
'album_type': album_type,
'release_date': release.get('release_date', ''),
},
'album': dict(album_context), # copy so per-track mutations don't bleed
'duration_ms': track_item.get('duration_ms', 0),
'track_number': track_item.get('track_number', 0),
'disc_number': track_item.get('disc_number', 1),
'image_url': release_image,
}
# Create finding
@ -286,6 +342,24 @@ class DiscographyBackfillJob(RepairJob):
)
result.findings_created += 1
missing_count += 1
# Auto-wishlist mode: also push to wishlist now. The
# finding still gets created so the user has a log of
# what the backfill picked up.
if auto_add:
try:
context.db.add_to_wishlist(
spotify_track_data=track_data,
failure_reason='Discography backfill — missing from library (auto-added)',
source_type='repair',
source_info={
'job': 'discography_backfill',
'artist': artist_name,
'auto_added': True,
},
)
except Exception as wl_err:
logger.debug("Auto-add to wishlist failed for '%s': %s", track_name, wl_err)
except Exception as e:
logger.debug("Error creating finding for %s: %s", track_name, e)
result.errors += 1

@ -434,7 +434,7 @@ class RepairWorker:
forced_job = self._force_run_queue.pop(0)
if forced_job:
self._run_job(forced_job)
self._run_job(forced_job, forced=True)
if self._sleep_or_stop(2):
break
continue
@ -518,8 +518,13 @@ class RepairWorker:
return best_job_id
def _run_job(self, job_id: str):
"""Execute a single job and record the run."""
def _run_job(self, job_id: str, forced: bool = False):
"""Execute a single job and record the run.
When forced=True, the user explicitly triggered this via "Run Now"
the job runs even if the master worker is paused, and wait_if_paused()
does not block.
"""
job = self._jobs.get(job_id)
if not job:
return
@ -568,7 +573,7 @@ class RepairWorker:
create_finding=self._create_finding,
should_stop=lambda: self.should_stop,
stop_event=self._stop_event,
is_paused=lambda: not self.enabled,
is_paused=(lambda: False) if forced else (lambda: not self.enabled),
update_progress=self._update_progress,
report_progress=_report_progress,
)
@ -2691,7 +2696,8 @@ class RepairWorker:
'single_album_redundant', 'mbid_mismatch',
'album_tag_inconsistency',
'incomplete_album', 'path_mismatch',
'missing_lossy_copy')
'missing_lossy_copy',
'missing_discography_track', 'acoustid_mismatch')
placeholders = ','.join(['?'] * len(fixable_types))
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
params = list(fixable_types)

@ -37,7 +37,7 @@ _log_dir = Path(_log_path).parent
logger = setup_logging(_log_level, _log_path)
# App version — single source of truth for backup metadata, version-info endpoint, etc.
_SOULSYNC_BASE_VERSION = "2.35"
_SOULSYNC_BASE_VERSION = "2.36"
def _build_version_string():
"""Append short commit hash to version when available (e.g. 2.35+abc1234)."""
@ -22534,10 +22534,22 @@ def get_version_info():
"• Creates findings for missing tracks — review and click 'Add to Wishlist' to queue downloads",
"• Respects all content filters (live, remix, acoustic, compilation, instrumental)",
"• Release type filters (album, EP, single) with configurable defaults",
"• Optional 'auto-add to wishlist' setting — create findings AND push to wishlist in one pass",
"• 3-option fix prompt (Add to Wishlist / Just Clear / Cancel) for manual review",
"• Batched in-memory library matching — same fast path the Library pages use",
"• Opt-in, disabled by default — runs weekly, processes up to 50 artists per run",
"• Rate-limited to avoid hammering metadata APIs",
],
},
{
"title": "Repair 'Run Now' Honored While Paused",
"description": "Force-running a repair job no longer stalls forever when the master repair worker is paused",
"features": [
"• Jobs queued via 'Run Now' run to completion even if the master worker is paused",
"• Fixes silent stalls where Discography Backfill logged 'scanning 50 artists' then did nothing",
"• Master-pause still blocks scheduled runs — this only affects explicit user-triggered runs",
],
},
{
"title": "Multi-Artist Tagging",
"description": "Enhanced control over how multiple artists are written to audio file tags",

@ -3599,9 +3599,18 @@ function closeHelperSearch() {
// ═══════════════════════════════════════════════════════════════════════════
const WHATS_NEW = {
'2.35': [
'2.36': [
// --- April 21, 2026 ---
{ date: 'April 21, 2026' },
{ title: 'Fix Discography Backfill Stalling When Repair Worker Paused', desc: 'Force-running a job via "Run Now" stalled forever when the master repair worker was paused. The job entered the scan function, logged its starting banner, then blocked on the first wait_if_paused check. Force-run now bypasses the master-pause — scheduled runs still respect it', page: 'library' },
{ title: 'Discography Backfill: 3-Option Fix Dialog', desc: 'Clicking Fix on a missing-track finding now prompts "Add to Wishlist", "Just Clear Finding", or "Cancel" instead of silently adding to wishlist. Bulk Fix shows the same prompt once for all selected backfill findings', page: 'library' },
{ title: 'Discography Backfill: Auto-Add to Wishlist Setting', desc: 'New opt-in setting in the Discography Backfill job config. When enabled, missing tracks are pushed straight to the wishlist during the scan AND a finding is created for the log. Default is off — you review and click Fix', page: 'library' },
{ title: 'Discography Backfill: Faster Batched Matching', desc: 'Each artist scan now pre-fetches the library albums + tracks once and matches in-memory — same fast path the Library and Artists pages use. Avoids thousands of per-track SQL queries on artists with big libraries', page: 'library' },
{ title: 'Discography Backfill: Rich Album Context per Finding', desc: 'Every finding now carries a full album dict (id, name, album_type, release_date, images, artists, total_tracks) matching the wishlist pipeline shape. No more generic "Add to Wishlist" loss of release metadata', page: 'library' },
{ title: 'Discography Backfill: Per-Artist Progress Logs', desc: 'Scan logs now show [N/50] Scanning ArtistName for each artist processed, with found-count or "no missing tracks" afterward. Makes it obvious whether the job is actually progressing' },
// --- April 20, 2026 (part 2) ---
{ date: 'April 20, 2026 (evening)' },
{ title: 'Massively Faster Artist Detail Page Loads', desc: 'Artist discography completion checks used to fire hundreds of SQL queries per page load — 15+ fuzzy title/artist searches per album times 30 albums per artist. Now pre-fetches the artist\'s library albums and tracks ONCE upfront, then matches everything in-memory. Same matching logic and accuracy, roughly 100x fewer SQL round-trips. Applies to both the Library artist page and the Artists search page', page: 'library' },
{ title: 'Fix Reorganize All Ignoring Album Type', desc: 'Reorganize All was sending every album — EPs, singles, and compilations — into the "Albums" folder because the $albumtype template variable silently defaulted to "Album". The variable is now resolved from the album\'s record_type (with track-count fallback) so ${albumtype}s produces the expected Albums/Singles/EPs/Compilations split', page: 'library' },
@ -3770,12 +3779,12 @@ const WHATS_NEW = {
function _getCurrentVersion() {
const btn = document.querySelector('.version-button');
return btn ? btn.textContent.trim().replace('v', '') : '2.35';
return btn ? btn.textContent.trim().replace('v', '') : '2.36';
}
function _getLatestWhatsNewVersion() {
const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a));
return versions[0] || '2.35';
return versions[0] || '2.36';
}
function openWhatsNew() {

@ -65048,6 +65048,16 @@ function switchRepairTab(tab) {
else if (tab === 'history') loadRepairHistory();
}
// Turn a snake_case setting key into a human label. Handles acronym fix-ups
// (EP, ID, URL, MB, AC, OS) that the naive Title-Case would otherwise botch.
function _prettifyRepairSettingKey(key) {
const words = key.replace(/^_+/, '').split('_');
const acronyms = { 'eps': 'EPs', 'id': 'ID', 'url': 'URL', 'mb': 'MB',
'ac': 'AC', 'os': 'OS', 'api': 'API', 'mp3': 'MP3',
'flac': 'FLAC', 'cd': 'CD' };
return words.map(w => acronyms[w.toLowerCase()] || (w.charAt(0).toUpperCase() + w.slice(1))).join(' ');
}
async function loadRepairJobs() {
const container = document.getElementById('repair-jobs-list');
if (!container) return;
@ -65124,7 +65134,13 @@ async function loadRepairJobs() {
let settingsHtml = '';
if (job.settings && Object.keys(job.settings).length > 0) {
const settingsRows = Object.entries(job.settings).map(([key, val]) => {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
// Section header: keys starting with `_section_` render as a
// group divider + title instead of a setting row. The value
// is the human-readable title.
if (key.startsWith('_section_')) {
return `<div class="repair-setting-section">${val}</div>`;
}
const label = _prettifyRepairSettingKey(key);
const inputType = typeof val === 'boolean' ? 'checkbox' :
typeof val === 'number' ? 'number' : 'text';
const inputVal = inputType === 'checkbox' ?
@ -65218,14 +65234,16 @@ function showRepairJobHelp(jobId) {
let overlay = document.getElementById('repair-help-overlay');
if (overlay) overlay.remove();
// Build settings summary
// Build settings summary (skip `_section_` group-header sentinels)
let settingsHtml = '';
if (job.settings && Object.keys(job.settings).length > 0) {
const rows = Object.entries(job.settings).map(([key, val]) => {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
const display = typeof val === 'boolean' ? (val ? 'Yes' : 'No') : val;
return `<div class="repair-help-setting"><span class="repair-help-setting-key">${label}</span><span class="repair-help-setting-val">${display}</span></div>`;
}).join('');
const rows = Object.entries(job.settings)
.filter(([key]) => !key.startsWith('_section_'))
.map(([key, val]) => {
const label = _prettifyRepairSettingKey(key);
const display = typeof val === 'boolean' ? (val ? 'Yes' : 'No') : val;
return `<div class="repair-help-setting"><span class="repair-help-setting-key">${label}</span><span class="repair-help-setting-val">${display}</span></div>`;
}).join('');
settingsHtml = `<div class="repair-help-settings-section">
<div class="repair-help-section-title">Current Settings</div>
${rows}
@ -66441,7 +66459,45 @@ async function fixAllMatchingFindings() {
// If fixing orphan files or dead files, prompt for action FIRST
let fixAction = null;
if (jobId === 'dead_file_cleaner') {
// Discography backfill: 3-option prompt (Add to Wishlist / Just Clear / Cancel).
// "Just Clear" bypasses bulk-fix entirely and goes through the clear endpoint,
// which is why it's handled inline and returns early.
if (jobId === 'discography_backfill') {
const choice = await _promptDiscographyBackfillAction(_repairFindingsTotal);
if (!choice) return;
if (choice === 'dismiss') {
if (!await showConfirmDialog({
title: 'Clear All Discography Findings',
message: `Clear all ${_repairFindingsTotal} discography backfill findings without adding any to the wishlist? Tracks can be re-detected next scan.`,
confirmText: 'Clear All',
destructive: false
})) return;
showToast(`Clearing ${_repairFindingsTotal} findings...`, 'info');
try {
const resp = await fetch('/api/repair/findings/clear', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ job_id: 'discography_backfill', status: 'pending' })
});
const result = await resp.json();
if (result.success) {
showToast(`Cleared ${result.deleted} findings`, 'success');
} else {
showToast(result.error || 'Clear failed', 'error');
}
} catch (err) {
console.error('Error clearing findings:', err);
showToast('Error clearing findings', 'error');
}
_repairSelectedFindings.clear();
loadRepairFindingsDashboard();
loadRepairFindings();
updateRepairStatus();
return;
}
// 'add_to_wishlist' falls through to bulk-fix. No destructive warning —
// the backend handler only adds tracks to the wishlist.
} else if (jobId === 'dead_file_cleaner') {
fixAction = await _promptDeadFileAction();
if (!fixAction) return;
} else if (jobId === 'orphan_file_detector' || _isMassOrphanFix(jobId, _repairFindingsTotal)) {
@ -66586,6 +66642,18 @@ async function fixRepairFinding(id, findingType) {
fixAction = await _promptAcoustidAction();
if (!fixAction) return;
}
// Discography backfill: add to wishlist or just clear the finding
if (findingType === 'missing_discography_track') {
const choice = await _promptDiscographyBackfillAction(1);
if (!choice) return; // cancel
if (choice === 'dismiss') {
// User just wants to remove the finding without adding to wishlist
await dismissRepairFinding(id);
return;
}
// 'add_to_wishlist' — fall through to the fix endpoint. The handler
// already defaults to adding to wishlist, so no fix_action is needed.
}
const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`);
const fixBtn = card ? card.querySelector('.repair-finding-btn.fix') : null;
@ -66727,6 +66795,46 @@ function _promptAcoustidAction() {
});
}
function _promptDiscographyBackfillAction(count = 1) {
const isSingle = count <= 1;
const headerText = isSingle ? 'Missing Discography Track' : `Missing Discography Tracks (${count})`;
const bodyText = isSingle
? 'Add this track to the wishlist for automatic download, or just clear the finding?'
: `Add all ${count} selected tracks to the wishlist for automatic download, or just clear the findings?`;
const addLabel = isSingle ? 'Add to Wishlist' : `Add All ${count} to Wishlist`;
const clearLabel = isSingle ? 'Just Clear Finding' : 'Just Clear Findings';
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 id="_dbf-header" style="font-size:1.1em;font-weight:600;color:#fff;margin-bottom:8px;"></div>
<div id="_dbf-body" style="font-size:0.88em;color:rgba(255,255,255,0.6);margin-bottom:20px;"></div>
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
<button id="_dbf-add" 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;"></button>
<button id="_dbf-dismiss" style="padding:10px 20px;border-radius:10px;border:1px solid rgba(102,126,234,0.4);background:rgba(102,126,234,0.15);color:#667eea;font-weight:500;cursor:pointer;font-family:inherit;"></button>
</div>
<button id="_dbf-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>
`;
// Assign text content (avoids HTML-escaping gotchas with dynamic values)
overlay.querySelector('#_dbf-header').textContent = headerText;
overlay.querySelector('#_dbf-body').textContent = bodyText;
overlay.querySelector('#_dbf-add').textContent = addLabel;
overlay.querySelector('#_dbf-dismiss').textContent = clearLabel;
document.body.appendChild(overlay);
overlay.querySelector('#_dbf-add').onclick = () => { overlay.remove(); resolve('add_to_wishlist'); };
overlay.querySelector('#_dbf-dismiss').onclick = () => { overlay.remove(); resolve('dismiss'); };
overlay.querySelector('#_dbf-cancel').onclick = () => { overlay.remove(); resolve(null); };
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
});
}
async function resolveRepairFinding(id) {
try {
await fetch(`/api/repair/findings/${id}/resolve`, { method: 'POST' });
@ -66813,6 +66921,17 @@ async function bulkFixFindings() {
if (!acoustidFixAction) return;
}
// If any selected findings are discography backfill, prompt once (add-to-wishlist vs clear)
const selectedBackfillCards = ids.filter(id => {
const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`);
return card && card.dataset.jobId === 'discography_backfill';
});
let backfillAction = null;
if (selectedBackfillCards.length > 0) {
backfillAction = await _promptDiscographyBackfillAction(selectedBackfillCards.length);
if (!backfillAction) return;
}
let fixed = 0, failed = 0, lastError = '';
showToast(`Fixing ${ids.length} findings...`, 'info');
@ -66823,10 +66942,27 @@ async function bulkFixFindings() {
const isOrphan = card && card.dataset.jobId === 'orphan_file_detector';
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';
// Discography backfill "Just Clear" path uses the dismiss endpoint,
// not the fix endpoint — so handle it inline before the fix call.
if (isBackfill && backfillAction === 'dismiss') {
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 };
// Discography backfill "Add to Wishlist" falls through with empty body
// — the fix handler already adds to wishlist by default.
const response = await fetch(`/api/repair/findings/${id}/fix`, {
method: 'POST',

@ -49966,8 +49966,13 @@ tr.tag-diff-same {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
padding: 7px 0;
gap: 12px;
border-bottom: 1px dashed rgba(255, 255, 255, 0.06);
}
.repair-setting-row:last-child {
border-bottom: none;
}
.repair-setting-row label {
@ -49975,6 +49980,25 @@ tr.tag-diff-same {
color: rgba(255, 255, 255, 0.5);
}
/* Section header inside a repair job settings panel used to group related
toggles (e.g. "Release Types" above include_albums/eps/singles). Rendered
from `_section_*` sentinel keys in a job's default_settings. */
.repair-setting-section {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
padding: 12px 0 4px;
margin-top: 4px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.repair-setting-section:first-child {
margin-top: 0;
padding-top: 4px;
}
.repair-setting-input {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);

Loading…
Cancel
Save