From 93e036848b6a3e41acbdf25166e7972018e45d0b Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 20 Apr 2026 18:03:11 -0700
Subject: [PATCH 01/34] Add ${var} delimiter syntax for path templates
Users can now append literal text to template variables using curly
braces: ${albumtype}s produces "Albums", "Singles", "EPs". Without
braces, $albumtypes was rejected as an unknown variable by validation.
Both syntaxes work: $albumtype (plain) and ${albumtype} (delimited).
Bracket vars are resolved first to prevent partial matching conflicts.
Validation updated for album, single, and playlist templates.
---
web_server.py | 19 +++++++++++++++++++
webui/index.html | 2 +-
webui/static/script.js | 34 +++++++++++++++++++---------------
3 files changed, 39 insertions(+), 16 deletions(-)
diff --git a/web_server.py b/web_server.py
index d888fda3..12665afb 100644
--- a/web_server.py
+++ b/web_server.py
@@ -18498,6 +18498,25 @@ def _apply_path_template(template: str, context: dict) -> str:
album_artist_value = resolved
except Exception:
pass
+ # Support ${var} delimited syntax (e.g. ${albumtype}s → Albums)
+ # Must run before $var replacements to prevent partial matching
+ _bracket_map = {
+ 'albumartist': album_artist_value,
+ 'albumtype': clean_context.get('albumtype', 'Album'),
+ 'playlist': clean_context.get('playlist_name', ''),
+ 'artistletter': (clean_context.get('artist', 'U') or 'U')[0].upper(),
+ 'artist': clean_context.get('artist', 'Unknown Artist'),
+ 'album': clean_context.get('album', 'Unknown Album'),
+ 'title': clean_context.get('title', 'Unknown Track'),
+ 'track': f"{clean_context.get('track_number', 1):02d}",
+ 'disc': str(clean_context.get('disc_number', 1)),
+ 'discnum': str(clean_context.get('disc_number', 1)),
+ 'year': str(clean_context.get('year', '')),
+ 'quality': clean_context.get('quality', ''),
+ }
+ for var_name, val in _bracket_map.items():
+ result = result.replace('${' + var_name + '}', val)
+
result = result.replace('$albumartist', album_artist_value)
result = result.replace('$albumtype', clean_context.get('albumtype', 'Album'))
result = result.replace('$playlist', clean_context.get('playlist_name', ''))
diff --git a/webui/index.html b/webui/index.html
index f1c63959..fcca3368 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -5068,7 +5068,7 @@
Variables: $albumartist, $artist, $artistletter, $album, $albumtype,
- $title, $track, $disc (01), $discnum (1), $year, $quality (filename only)
+ $title, $track, $disc (01), $discnum (1), $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Albums
diff --git a/webui/static/script.js b/webui/static/script.js
index 8b7cdc96..e9a3aacc 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -5703,17 +5703,19 @@ function validateFileOrganizationTemplates() {
errors.push('Album template cannot have consecutive slashes //');
}
// Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.)
- const albumVarPattern = /\$[a-zA-Z]+/g;
+ const albumVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
const foundVars = albumPath.match(albumVarPattern) || [];
foundVars.forEach(v => {
- const lowerVar = v.toLowerCase();
+ // Normalize ${var} to $var for validation
+ const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
+ const lowerVar = normalized.toLowerCase();
// Check if lowercase version exists in valid vars
const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
- errors.push(`Invalid variable "${v}" in album template. Valid: ${validVars.album.join(', ')}`);
- } else if (v !== lowerVar && validVars.album.includes(lowerVar)) {
+ errors.push(`Invalid variable "${normalized}" in album template. Valid: ${validVars.album.join(', ')}`);
+ } else if (normalized !== lowerVar && validVars.album.includes(lowerVar)) {
// Variable is valid but has wrong case
- errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
+ errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
}
});
}
@@ -5730,15 +5732,16 @@ function validateFileOrganizationTemplates() {
if (singlePath.includes('//')) {
errors.push('Single template cannot have consecutive slashes //');
}
- const singleVarPattern = /\$[a-zA-Z]+/g;
+ const singleVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
const foundVars = singlePath.match(singleVarPattern) || [];
foundVars.forEach(v => {
- const lowerVar = v.toLowerCase();
+ const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
+ const lowerVar = normalized.toLowerCase();
const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
- errors.push(`Invalid variable "${v}" in single template. Valid: ${validVars.single.join(', ')}`);
- } else if (v !== lowerVar && validVars.single.includes(lowerVar)) {
- errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
+ errors.push(`Invalid variable "${normalized}" in single template. Valid: ${validVars.single.join(', ')}`);
+ } else if (normalized !== lowerVar && validVars.single.includes(lowerVar)) {
+ errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
}
});
}
@@ -5757,15 +5760,16 @@ function validateFileOrganizationTemplates() {
if (playlistPath.includes('//')) {
errors.push('Playlist template cannot have consecutive slashes //');
}
- const playlistVarPattern = /\$[a-zA-Z]+/g;
+ const playlistVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g;
const foundVars = playlistPath.match(playlistVarPattern) || [];
foundVars.forEach(v => {
- const lowerVar = v.toLowerCase();
+ const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v;
+ const lowerVar = normalized.toLowerCase();
const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar);
if (!isValid) {
- errors.push(`Invalid variable "${v}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
- } else if (v !== lowerVar && validVars.playlist.includes(lowerVar)) {
- errors.push(`Variable "${v}" should be lowercase: "${lowerVar}"`);
+ errors.push(`Invalid variable "${normalized}" in playlist template. Valid: ${validVars.playlist.join(', ')}`);
+ } else if (normalized !== lowerVar && validVars.playlist.includes(lowerVar)) {
+ errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`);
}
});
}
From dad915dc2caffc75013e685dad057758381956aa Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 20 Apr 2026 21:07:16 -0700
Subject: [PATCH 02/34] Add ${var} hint text to single, playlist, and video
template hints
---
webui/index.html | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/webui/index.html b/webui/index.html
index fcca3368..28ea8579 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -5075,21 +5075,21 @@
- Variables: $albumartist, $artist, $artistletter, $title, $track (01), $album, $albumtype, $year, $quality (filename only)
+ Variables: $albumartist, $artist, $artistletter, $title, $track (01), $album, $albumtype, $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Singles
- Variables: $playlist, $albumartist, $artist, $artistletter, $title, $year, $quality (filename only)
+ Variables: $playlist, $albumartist, $artist, $artistletter, $title, $year, $quality (filename only). Use ${var} to append text
- Variables: $artist, $artistletter, $title, $year. Default: $artist/$title-video → Artist/Title-video.mp4
+ Variables: $artist, $artistletter, $title, $year. Use ${var} to append text. Default: $artist/$title-video
From 7287a9d184f18ec9e817a390bb286e1049492899 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 20 Apr 2026 21:11:45 -0700
Subject: [PATCH 03/34] Fix test using old deezer_track_id column name
The unknown_artist_fixer was updated to use deezer_id (matching the
actual tracks table column) but the test still passed deezer_track_id
in the track dict, causing the deezer lookup to miss and fall back
to Spotify.
---
tests/test_unknown_artist_fixer.py | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/tests/test_unknown_artist_fixer.py b/tests/test_unknown_artist_fixer.py
index 1ee99bc0..4ff15f59 100644
--- a/tests/test_unknown_artist_fixer.py
+++ b/tests/test_unknown_artist_fixer.py
@@ -110,7 +110,7 @@ def test_unknown_artist_fixer_uses_primary_source_track_id_first(monkeypatch):
"title": "Unknown Title",
"album_title": "Unknown Album",
"spotify_track_id": "sp-1",
- "deezer_track_id": "dz-1",
+ "deezer_id": "dz-1",
"itunes_track_id": "",
}
@@ -176,7 +176,7 @@ def test_unknown_artist_fixer_searches_primary_source_first(monkeypatch):
"title": "Matching Title",
"album_title": "Matching Album",
"spotify_track_id": "",
- "deezer_track_id": "",
+ "deezer_id": "",
"itunes_track_id": "",
}
@@ -229,7 +229,7 @@ def test_unknown_artist_fixer_supports_hydrabase_title_search(monkeypatch):
"title": "Hydra Match",
"album_title": "Hydra Album",
"spotify_track_id": "",
- "deezer_track_id": "",
+ "deezer_id": "",
"itunes_track_id": "",
}
From ed97fecc318199942b60fe46737ec4b539d52e54 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 20 Apr 2026 22:04:38 -0700
Subject: [PATCH 04/34] Add Discography Backfill maintenance job
New repair job that scans each artist in the library, fetches their
full discography from metadata sources, and creates findings for any
tracks not already owned. Users review findings and click "Add to
Wishlist" to queue missing tracks for download.
Respects content filters (live/remix/acoustic/instrumental/compilation)
and release type filters (album/EP/single). Opt-in, disabled by default,
runs weekly, processes up to 50 artists per run with rate limiting.
---
core/repair_jobs/__init__.py | 1 +
core/repair_jobs/discography_backfill.py | 387 +++++++++++++++++++++++
core/repair_worker.py | 21 ++
webui/static/script.js | 1 +
4 files changed, 410 insertions(+)
create mode 100644 core/repair_jobs/discography_backfill.py
diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py
index 159dc7bc..c6f780e8 100644
--- a/core/repair_jobs/__init__.py
+++ b/core/repair_jobs/__init__.py
@@ -43,6 +43,7 @@ _JOB_MODULES = [
'core.repair_jobs.album_tag_consistency',
'core.repair_jobs.live_commentary_cleaner',
'core.repair_jobs.unknown_artist_fixer',
+ 'core.repair_jobs.discography_backfill',
]
diff --git a/core/repair_jobs/discography_backfill.py b/core/repair_jobs/discography_backfill.py
new file mode 100644
index 00000000..6b307293
--- /dev/null
+++ b/core/repair_jobs/discography_backfill.py
@@ -0,0 +1,387 @@
+"""Discography Backfill Job — finds missing albums/tracks for library artists."""
+
+from core.metadata_service import (
+ get_album_tracks_for_source,
+ get_artist_discography,
+ get_primary_source,
+ MetadataLookupOptions,
+)
+from core.repair_jobs import register_job
+from core.repair_jobs.base import JobContext, JobResult, RepairJob
+from core.watchlist_scanner import (
+ is_acoustic_version,
+ is_compilation_album,
+ is_instrumental_version,
+ is_live_version,
+ is_remix_version,
+)
+from utils.logging_config import get_logger
+
+logger = get_logger("repair_job.discography_backfill")
+
+
+@register_job
+class DiscographyBackfillJob(RepairJob):
+ job_id = 'discography_backfill'
+ display_name = 'Discography Backfill'
+ 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'
+ '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)'
+ )
+ icon = 'repair-icon-backfill'
+ default_enabled = False
+ default_interval_hours = 168 # Weekly
+ default_settings = {
+ 'include_albums': True,
+ 'include_eps': True,
+ 'include_singles': False,
+ 'include_live': False,
+ 'include_remixes': False,
+ 'include_acoustic': False,
+ 'include_compilations': False,
+ 'include_instrumentals': False,
+ 'max_artists_per_run': 50,
+ }
+ auto_fix = False
+
+ def scan(self, context: JobContext) -> JobResult:
+ result = JobResult()
+ settings = self._get_settings(context)
+
+ max_artists = settings.get('max_artists_per_run', 50)
+
+ # Fetch all library artists with their metadata source IDs
+ artists = self._get_library_artists(context)
+ if not artists:
+ logger.info("No artists in library to scan")
+ return result
+
+ total = min(len(artists), max_artists)
+ if context.update_progress:
+ context.update_progress(0, total)
+ if context.report_progress:
+ context.report_progress(
+ phase=f'Scanning discography for {total} artists...',
+ total=total,
+ )
+
+ logger.info("Discography backfill: scanning %d artists (of %d total)", total, len(artists))
+ primary_source = get_primary_source()
+
+ for i, artist in enumerate(artists[:max_artists]):
+ if context.check_stop():
+ return result
+ if i % 5 == 0 and context.wait_if_paused():
+ return result
+
+ artist_id = artist['id']
+ artist_name = artist['name']
+
+ if context.report_progress:
+ context.report_progress(
+ scanned=i + 1, total=total,
+ phase=f'Scanning {i + 1} / {total}',
+ log_line=f'Fetching discography: {artist_name}',
+ log_type='info',
+ )
+
+ 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)
+ except Exception as e:
+ logger.warning("Error scanning discography for %s: %s", artist_name, e)
+ result.errors += 1
+
+ if context.update_progress and (i + 1) % 3 == 0:
+ context.update_progress(i + 1, total)
+
+ # Rate limit between artists
+ if context.sleep_or_stop(1.0):
+ return result
+
+ if context.update_progress:
+ context.update_progress(total, total)
+
+ logger.info(
+ "Discography backfill complete: %d artists scanned, %d missing tracks found, %d errors",
+ result.scanned, result.findings_created, result.errors,
+ )
+ return result
+
+ def _scan_artist(self, context, artist, settings, primary_source, result):
+ """Scan one artist's discography and create findings for missing tracks."""
+ artist_name = artist['name']
+ result.scanned += 1
+
+ # Build source ID map for more accurate lookups
+ source_ids = {}
+ if artist.get('spotify_artist_id'):
+ source_ids['spotify'] = artist['spotify_artist_id']
+ if artist.get('itunes_artist_id'):
+ source_ids['itunes'] = artist['itunes_artist_id']
+ if artist.get('deezer_artist_id'):
+ source_ids['deezer'] = artist['deezer_artist_id']
+
+ # Fetch full discography
+ discography = get_artist_discography(
+ artist_id=str(artist['id']),
+ artist_name=artist_name,
+ options=MetadataLookupOptions(
+ allow_fallback=True,
+ skip_cache=False,
+ artist_source_ids=source_ids if source_ids else None,
+ ),
+ )
+
+ if not discography:
+ result.skipped += 1
+ return 0
+
+ source = discography.get('source', primary_source)
+ albums = discography.get('albums', [])
+ singles = discography.get('singles', [])
+ missing_count = 0
+ active_server = None
+ if context.config_manager:
+ active_server = context.config_manager.get_active_media_server()
+
+ # Process albums and singles
+ for release in albums + singles:
+ if context.check_stop():
+ return missing_count
+
+ release_name = release.get('name', '')
+ 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', '')
+
+ # Filter by release type
+ if not self._should_include_release(total_tracks, album_type, settings):
+ continue
+
+ # Filter compilation albums
+ if not settings.get('include_compilations', False):
+ if is_compilation_album(release_name):
+ continue
+
+ # Fetch tracks for this release
+ try:
+ tracks_data = get_album_tracks_for_source(source, str(release_id))
+ except Exception:
+ tracks_data = None
+
+ if not tracks_data:
+ continue
+
+ # Extract track items
+ items = []
+ if isinstance(tracks_data, dict):
+ items = tracks_data.get('items', [])
+ elif isinstance(tracks_data, list):
+ items = tracks_data
+
+ if not items:
+ continue
+
+ for track_item in items:
+ if context.check_stop():
+ return missing_count
+
+ track_name = track_item.get('name', '')
+ if not track_name:
+ continue
+
+ # Extract artist name from track
+ track_artists = track_item.get('artists', [])
+ if track_artists:
+ first_artist = track_artists[0]
+ if isinstance(first_artist, dict):
+ track_artist = first_artist.get('name', artist_name)
+ else:
+ track_artist = str(first_artist)
+ else:
+ track_artist = artist_name
+
+ # Content type filters
+ if not settings.get('include_live', False):
+ if is_live_version(track_name, release_name):
+ continue
+ if not settings.get('include_remixes', False):
+ if is_remix_version(track_name, release_name):
+ continue
+ if not settings.get('include_acoustic', False):
+ if is_acoustic_version(track_name, release_name):
+ continue
+ if not settings.get('include_instrumentals', False):
+ if is_instrumental_version(track_name, release_name):
+ continue
+
+ # Check if track already exists in library
+ db_track, confidence = context.db.check_track_exists(
+ track_name, track_artist,
+ confidence_threshold=0.7,
+ server_source=active_server,
+ album=release_name,
+ )
+ if db_track and confidence >= 0.7:
+ continue # Already owned
+
+ # Check if already in wishlist
+ try:
+ track_id = track_item.get('id', '')
+ if track_id and self._is_in_wishlist(context.db, track_id):
+ continue
+ except Exception:
+ pass
+
+ # Build track data for 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', ''),
+ },
+ 'duration_ms': track_item.get('duration_ms', 0),
+ 'track_number': track_item.get('track_number', 0),
+ 'disc_number': track_item.get('disc_number', 1),
+ }
+
+ # Create finding
+ if context.create_finding:
+ try:
+ context.create_finding(
+ job_id=self.job_id,
+ finding_type='missing_discography_track',
+ severity='info',
+ entity_type='track',
+ entity_id=str(track_data['id']),
+ file_path=None,
+ title=f'Missing: {track_name}',
+ description=(
+ f'"{track_name}" by {track_artist} from '
+ f'"{release_name}" is not in your library.'
+ ),
+ details={
+ 'track_data': track_data,
+ 'artist_name': artist_name,
+ 'album_name': release_name,
+ 'album_image_url': release_image,
+ 'source': source,
+ },
+ )
+ result.findings_created += 1
+ missing_count += 1
+ except Exception as e:
+ logger.debug("Error creating finding for %s: %s", track_name, e)
+ result.errors += 1
+
+ return missing_count
+
+ @staticmethod
+ def _should_include_release(total_tracks, album_type, settings):
+ """Check if a release should be included based on type settings."""
+ # Use album_type from metadata source when available
+ normalized = (album_type or '').lower()
+ if normalized == 'compilation':
+ return settings.get('include_compilations', False)
+ if normalized in ('single',):
+ return settings.get('include_singles', False)
+ if normalized in ('ep',):
+ return settings.get('include_eps', True)
+ # Fall back to track count heuristic
+ if total_tracks >= 7:
+ return settings.get('include_albums', True)
+ elif total_tracks >= 4:
+ return settings.get('include_eps', True)
+ elif total_tracks >= 1:
+ return settings.get('include_singles', False)
+ return settings.get('include_albums', True)
+
+ @staticmethod
+ def _is_in_wishlist(db, track_id):
+ """Check if a track ID is already in the wishlist."""
+ conn = db._get_connection()
+ try:
+ cursor = conn.cursor()
+ cursor.execute(
+ "SELECT COUNT(*) FROM wishlist_tracks WHERE spotify_track_id = ?",
+ (str(track_id),),
+ )
+ return cursor.fetchone()[0] > 0
+ finally:
+ conn.close()
+
+ def _get_library_artists(self, context):
+ """Get all artists from the library database with source IDs."""
+ conn = None
+ try:
+ conn = context.db._get_connection()
+ cursor = conn.cursor()
+
+ # Check which columns exist
+ cursor.execute("PRAGMA table_info(artists)")
+ columns = {col[1] for col in cursor.fetchall()}
+
+ select = ["id", "name"]
+ if 'spotify_artist_id' in columns:
+ select.append("spotify_artist_id")
+ if 'itunes_artist_id' in columns:
+ select.append("itunes_artist_id")
+ if 'deezer_artist_id' in columns:
+ select.append("deezer_artist_id")
+
+ cursor.execute(f"""
+ SELECT {', '.join(select)}
+ FROM artists
+ WHERE name IS NOT NULL AND name != '' AND name != 'Unknown Artist'
+ ORDER BY name
+ """)
+ return [dict(row) for row in cursor.fetchall()]
+ except Exception as e:
+ logger.error("Error fetching library artists: %s", e, exc_info=True)
+ return []
+ finally:
+ if conn:
+ conn.close()
+
+ def _get_settings(self, context: JobContext) -> dict:
+ if not context.config_manager:
+ return self.default_settings.copy()
+ cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {})
+ merged = self.default_settings.copy()
+ merged.update(cfg)
+ return merged
+
+ def estimate_scope(self, context: JobContext) -> int:
+ conn = None
+ try:
+ conn = context.db._get_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ SELECT COUNT(*) FROM artists
+ WHERE name IS NOT NULL AND name != '' AND name != 'Unknown Artist'
+ """)
+ row = cursor.fetchone()
+ settings = self._get_settings(context)
+ max_artists = settings.get('max_artists_per_run', 50)
+ return min(row[0] if row else 0, max_artists)
+ except Exception:
+ return 0
+ finally:
+ if conn:
+ conn.close()
diff --git a/core/repair_worker.py b/core/repair_worker.py
index 207f9df0..0810677f 100644
--- a/core/repair_worker.py
+++ b/core/repair_worker.py
@@ -845,12 +845,33 @@ class RepairWorker:
'unwanted_content': self._fix_unwanted_content,
'unknown_artist': self._fix_unknown_artist,
'acoustid_mismatch': self._fix_acoustid_mismatch,
+ 'missing_discography_track': self._fix_discography_backfill,
}
handler = handlers.get(finding_type)
if not handler:
return {'success': False, 'error': f'No fix available for finding type: {finding_type}'}
return handler(entity_type, entity_id, file_path, details)
+ def _fix_discography_backfill(self, entity_type, entity_id, file_path, details):
+ """Add missing discography track to wishlist."""
+ track_data = details.get('track_data')
+ if not track_data:
+ return {'success': False, 'error': 'No track data in finding'}
+ try:
+ success = self.db.add_to_wishlist(
+ spotify_track_data=track_data,
+ failure_reason='Discography backfill — missing from library',
+ source_type='repair',
+ source_info={'job': 'discography_backfill', 'artist': details.get('artist_name', '')}
+ )
+ track_name = track_data.get('name', '?')
+ if success:
+ return {'success': True, 'action': 'added_to_wishlist',
+ 'message': f"Added '{track_name}' to wishlist"}
+ return {'success': False, 'error': f"Could not add '{track_name}' to wishlist (may already exist)"}
+ except Exception as e:
+ return {'success': False, 'error': str(e)}
+
def _fix_dead_file(self, entity_type, entity_id, file_path, details):
"""Fix a dead file reference. Action depends on details['_fix_action']:
'redownload' (default) — add to wishlist + remove DB entry
diff --git a/webui/static/script.js b/webui/static/script.js
index e9a3aacc..96b406f1 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -65544,6 +65544,7 @@ async function loadRepairFindings() {
incomplete_album: 'Auto-Fill',
missing_lossy_copy: 'Convert',
acoustid_mismatch: 'Fix',
+ missing_discography_track: 'Add to Wishlist',
};
container.innerHTML = items.map(f => {
From 9c15d0012838a76e9eb32eb7ea7f33c08506565c Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 20 Apr 2026 22:18:20 -0700
Subject: [PATCH 05/34] Enrich downloads page cards with artwork, artist,
album, and source badges
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The downloads page previously showed only title and source per download.
Now shows album artwork thumbnail, artist name, album name, source badge,
and quality badge (after post-processing). All metadata comes from the
existing matched_downloads_context — no extra API calls needed.
Falls back gracefully to title-only display when context metadata is
not available (e.g. orphaned Soulseek transfers with no task mapping).
---
web_server.py | 18 +++++++++++
webui/static/script.js | 24 +++++++++++++--
webui/static/style.css | 68 +++++++++++++++++++++++++++++++++++++++++-
3 files changed, 107 insertions(+), 3 deletions(-)
diff --git a/web_server.py b/web_server.py
index 12665afb..a5791e3d 100644
--- a/web_server.py
+++ b/web_server.py
@@ -10119,6 +10119,24 @@ def get_download_status():
print(f"Could not fetch YouTube/Tidal downloads for status: {streaming_error}")
traceback.print_exc()
+ # Enrich transfers with metadata from download context (artist, album, artwork)
+ with matched_context_lock:
+ for transfer in all_transfers:
+ ctx_key = _make_context_key(transfer.get('username', ''), transfer.get('filename', ''))
+ ctx = matched_downloads_context.get(ctx_key)
+ if ctx:
+ _sp_artist = ctx.get('spotify_artist') or {}
+ _sp_album = ctx.get('spotify_album') or {}
+ _sp_track = ctx.get('track_info') or {}
+ _sp_images = _sp_album.get('images') or []
+ transfer['_meta'] = {
+ 'artist': _sp_artist.get('name', ''),
+ 'album': _sp_album.get('name', ''),
+ 'artwork_url': _sp_images[0].get('url', '') if _sp_images and isinstance(_sp_images[0], dict) else '',
+ 'track_number': _sp_track.get('track_number'),
+ 'quality': ctx.get('_audio_quality', ''),
+ }
+
return jsonify({"transfers": all_transfers})
except Exception as e:
diff --git a/webui/static/script.js b/webui/static/script.js
index 96b406f1..afd8232c 100644
--- a/webui/static/script.js
+++ b/webui/static/script.js
@@ -17261,11 +17261,31 @@ function renderQueue(containerId, downloads, isActiveQueue) {
`;
}
+ // Enrich with metadata from backend context (artist, album, artwork)
+ const meta = item._meta || {};
+ const sourceLabels = { youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', lidarr: 'Lidarr' };
+ const sourceBadge = sourceLabels[item.username] || item.username;
+
html += `
-