diff --git a/core/repair_jobs/discography_backfill.py b/core/repair_jobs/discography_backfill.py index c443ac65..3170624a 100644 --- a/core/repair_jobs/discography_backfill.py +++ b/core/repair_jobs/discography_backfill.py @@ -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 diff --git a/core/repair_worker.py b/core/repair_worker.py index 4acd729a..89bacc7c 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -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) diff --git a/web_server.py b/web_server.py index 7c2b6624..9042854f 100644 --- a/web_server.py +++ b/web_server.py @@ -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", diff --git a/webui/static/helper.js b/webui/static/helper.js index 2b831555..be2a628d 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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() { diff --git a/webui/static/script.js b/webui/static/script.js index 16954d75..b5f40347 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 `