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 += `
-
+
+ ${meta.artwork_url + ? `` + : '
'} +
+
${title}
-
from ${item.username}
+ ${meta.artist || meta.album ? ` +
+ ${meta.artist ? `${escapeHtml(meta.artist)}` : ''} + ${meta.artist && meta.album ? '·' : ''} + ${meta.album ? `${escapeHtml(meta.album)}` : ''} +
+ ` : ''} +
+ ${sourceBadge} + ${meta.quality ? `${escapeHtml(meta.quality)}` : ''} +
${actionButtonHTML} diff --git a/webui/static/style.css b/webui/static/style.css index 0a68e3e4..0865189e 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -6874,6 +6874,9 @@ body.helper-mode-active #dashboard-activity-feed:hover { margin: 6px 0; padding: 12px; transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 12px; } .download-item:hover { @@ -6883,6 +6886,35 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(var(--accent-rgb), 0.6); } +.download-item__art { + width: 44px; + height: 44px; + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; +} +.download-item__art img { + width: 100%; + height: 100%; + object-fit: cover; +} +.download-item__art-placeholder { + width: 100%; + height: 100%; + background: rgba(255,255,255,0.05); + display: flex; + align-items: center; + justify-content: center; + color: rgba(255,255,255,0.25); + font-size: 18px; + border-radius: 6px; +} + +.download-item__info { + flex: 1; + min-width: 0; +} + .download-item__header { display: flex; justify-content: space-between; @@ -6894,12 +6926,45 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-size: 13px; font-weight: 600; color: #ffffff; - max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.download-item__meta { + display: flex; + align-items: center; + gap: 0; + font-size: 0.82em; + color: rgba(255,255,255,0.5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; +} +.download-item__sep { + margin: 0 5px; + opacity: 0.4; +} +.download-item__album { + opacity: 0.7; +} + +.download-item__badges { + display: flex; + gap: 6px; + margin-top: 3px; +} +.download-item__source, +.download-item__quality { + font-size: 0.7em; + padding: 1px 7px; + border-radius: 4px; + background: rgba(255,255,255,0.07); + color: rgba(255,255,255,0.45); + font-weight: 500; +} + .download-item__uploader { font-size: 10px; padding-left: 15px; @@ -6912,6 +6977,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { justify-content: center; flex-direction: row; gap: 15px; + flex-shrink: 0; } /* Progress Bar Styles */ From fd014e2745305d876d32c7938006c2c11ce861ee Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 20 Apr 2026 22:50:41 -0700 Subject: [PATCH 06/34] Use parent folder name as artist override in auto-import When staging files are organized as Artist/Albums/AlbumFolder or Artist/AlbumFolder, the auto-import now uses the parent folder name as the artist instead of trusting embedded file tags. Uses relative path from staging root to determine folder depth, so albums directly in staging root don't accidentally pick up container paths as artist names. Common category subfolder names (Albums, Singles, EPs, Mixtapes, etc.) are recognized and skipped. Fixes mixtapes and compilations where file tags have DJ names or incorrect artists (e.g. files tagged as "Slim" in a 2Pac folder). --- core/auto_import_worker.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/core/auto_import_worker.py b/core/auto_import_worker.py index d05af689..a564d862 100644 --- a/core/auto_import_worker.py +++ b/core/auto_import_worker.py @@ -968,6 +968,34 @@ class AutoImportWorker: artist_name = identification.get('artist_name', 'Unknown') album_name = identification.get('album_name', 'Unknown') image_url = identification.get('image_url', '') + + # Parent folder artist override: if the staging folder structure is + # Artist/Albums/AlbumName or Artist/AlbumName, use the parent folder + # as the artist name when the tag-extracted artist looks wrong. + # This handles mixtapes/compilations where embedded tags have DJ names. + try: + staging_root = self._resolve_staging_path() or self.staging_path + rel_path = os.path.relpath(candidate.path, staging_root) + parts = [p for p in rel_path.replace('\\', '/').split('/') if p] + + # parts[0] = artist folder, parts[1] = album or category subfolder, etc. + # Only attempt override if there's at least 2 levels (artist/album) + folder_artist = None + if len(parts) >= 2: + _category_names = {'albums', 'singles', 'eps', 'compilations', 'mixtapes', + 'discography', 'music', 'downloads'} + if len(parts) >= 3 and parts[1].lower() in _category_names: + # Artist/Albums/AlbumFolder → parts[0] is artist + folder_artist = parts[0] + elif parts[0].lower() not in _category_names: + # Artist/AlbumFolder → parts[0] is artist + folder_artist = parts[0] + + if folder_artist and folder_artist.lower() != artist_name.lower(): + logger.info(f"[Auto-Import] Parent folder artist '{folder_artist}' differs from tag artist '{artist_name}' — using folder artist") + artist_name = folder_artist + except Exception: + pass release_date = identification.get('release_date', '') or album_data.get('release_date', '') # Compute total discs From e39a3f2af7c982d30544e809c72b7f3e61df3753 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:18:10 -0700 Subject: [PATCH 07/34] Add multi-artist tagging options: separator, multi-value tags, feat-in-title Three new settings in Paths & Organization: - Artist Tag Separator: choose comma, semicolon, or slash between artists - Write multi-value ARTISTS tag: each artist as separate tag value for Navidrome/Jellyfin multi-artist linking (FLAC ARTISTS key, ID3 TPE1 multi-value, MP4 multi-entry) - Move featured artists to title: keep only primary artist in ARTIST tag, append others as (feat. ...) in track title All opt-in with defaults matching current behavior. Raw artist list stored on metadata dict for tag writers to access without re-parsing. --- web_server.py | 35 ++++++++++++++++++++++++++++++++--- webui/index.html | 24 ++++++++++++++++++++++++ webui/static/script.js | 8 +++++++- 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/web_server.py b/web_server.py index a5791e3d..ee850b76 100644 --- a/web_server.py +++ b/web_server.py @@ -18827,12 +18827,18 @@ def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_in # ── Write standard tags using format-specific API ── track_num_str = f"{metadata.get('track_number', 1)}/{metadata.get('total_tracks', 1)}" + _write_multi = config_manager.get('metadata_enhancement.tags.write_multi_artist', False) + _artists_list = metadata.get('_artists_list', []) + if isinstance(audio_file.tags, ID3): # MP3: write ID3 frames directly if metadata.get('title'): audio_file.tags.add(TIT2(encoding=3, text=[metadata['title']])) if metadata.get('artist'): audio_file.tags.add(TPE1(encoding=3, text=[metadata['artist']])) + # Multi-value: write each artist as separate TPE1 text value + if _write_multi and len(_artists_list) > 1: + audio_file.tags.add(TPE1(encoding=3, text=_artists_list)) if metadata.get('album_artist'): audio_file.tags.add(TPE2(encoding=3, text=[metadata['album_artist']])) if metadata.get('album'): @@ -18851,6 +18857,9 @@ def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_in audio_file['title'] = [metadata['title']] if metadata.get('artist'): audio_file['artist'] = [metadata['artist']] + # Multi-value: write ARTISTS tag with individual values + if _write_multi and len(_artists_list) > 1: + audio_file['artists'] = _artists_list if metadata.get('album_artist'): audio_file['albumartist'] = [metadata['album_artist']] if metadata.get('album'): @@ -18868,7 +18877,11 @@ def _enhance_file_metadata(file_path: str, context: dict, artist: dict, album_in if metadata.get('title'): audio_file['\xa9nam'] = [metadata['title']] if metadata.get('artist'): - audio_file['\xa9ART'] = [metadata['artist']] + # Multi-value: write each artist as separate list entry + if _write_multi and len(_artists_list) > 1: + audio_file['\xa9ART'] = _artists_list + else: + audio_file['\xa9ART'] = [metadata['artist']] if metadata.get('album_artist'): audio_file['aART'] = [metadata['album_artist']] if metadata.get('album'): @@ -19011,7 +19024,6 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> # Handle multiple artists from Spotify data original_search = context.get("original_search_result", {}) if 'artists' in original_search and isinstance(original_search['artists'], list) and len(original_search['artists']) > 0: - # Join all artists with semicolon separator (standard format) all_artists = [] for a in original_search['artists']: if isinstance(a, dict) and 'name' in a: @@ -19020,11 +19032,28 @@ def _extract_spotify_metadata(context: dict, artist: dict, album_info: dict) -> all_artists.append(a) else: all_artists.append(str(a)) - metadata['artist'] = ', '.join(all_artists) + + # Configurable artist separator (default: comma-space) + _artist_sep = config_manager.get('metadata_enhancement.tags.artist_separator', ', ') or ', ' + _feat_in_title = config_manager.get('metadata_enhancement.tags.feat_in_title', False) + + # Featured artist in title mode: keep only primary artist, append rest to title + if _feat_in_title and len(all_artists) > 1: + metadata['artist'] = all_artists[0] + _feat_str = ', '.join(all_artists[1:]) + _title = metadata.get('title', '') + if _title and not re.search(r'\b(feat\.?|ft\.?|featuring)\b', _title, re.IGNORECASE): + metadata['title'] = f"{_title} (feat. {_feat_str})" + else: + metadata['artist'] = _artist_sep.join(all_artists) + + # Store raw artist list for multi-value tag writing + metadata['_artists_list'] = all_artists print(f"Metadata: Using all artists: '{metadata['artist']}'") else: # Fallback to single artist metadata['artist'] = artist.get('name', '') + metadata['_artists_list'] = [metadata['artist']] if metadata['artist'] else [] print(f"Metadata: Using primary artist: '{metadata['artist']}'") # Resolve album_artist for consistent tagging across all tracks in an album. diff --git a/webui/index.html b/webui/index.html index 28ea8579..9fa2bc49 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5112,6 +5112,30 @@ Full artist list is always preserved in file metadata tags.
+
+ + + Separator between multiple artists in the ARTIST tag +
+
+ + Write each artist as a separate tag value for Navidrome/Jellyfin multi-artist support +
+
+ + Keep only primary artist in ARTIST tag, append others as (feat. ...) in title +
+