Add per-artist metadata source override for watchlist scans

Users can now override which metadata provider (Spotify, Deezer, Apple Music,
Discogs) is used when scanning a specific watchlist artist for new releases.
The selector appears in the artist config modal and only shows sources the
artist has enrichment IDs for. Default behavior is unchanged — all artists
use the global metadata source unless explicitly overridden.
pull/324/head
Broque Thomas 2 months ago
parent f749bb9604
commit b17a6e2dd7

@ -865,7 +865,14 @@ class WatchlistScanner:
Returns:
WatchlistDiscographyResult or None on error
"""
for source in self._watchlist_source_priority():
# Per-artist metadata source override — if set, use that source first with fallback
preferred = getattr(watchlist_artist, 'preferred_metadata_source', None)
if preferred and preferred in ('spotify', 'deezer', 'itunes', 'discogs'):
source_priority = list(get_source_priority(preferred))
else:
source_priority = self._watchlist_source_priority()
for source in source_priority:
result = self._resolve_watchlist_discography_for_source(watchlist_artist, source, last_scan_timestamp)
if result:
return result

@ -102,6 +102,7 @@ class WatchlistArtist:
include_compilations: bool = False
include_instrumentals: bool = False
lookback_days: Optional[int] = None # Per-artist override; None = use global setting
preferred_metadata_source: Optional[str] = None # Per-artist override; None = use global setting
profile_id: int = 1
@dataclass
@ -378,6 +379,9 @@ class MusicDatabase:
# Add iTunes artist ID column to watchlist_artists (migration)
self._add_watchlist_itunes_id_column(cursor)
# Add per-artist preferred_metadata_source column (migration)
self._add_watchlist_preferred_metadata_source_column(cursor)
# Make spotify_artist_id nullable for iTunes-only artists (migration)
self._fix_watchlist_spotify_id_nullable(cursor)
@ -1529,6 +1533,17 @@ class MusicDatabase:
logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}")
# Don't raise - this is a migration, database can still function
def _add_watchlist_preferred_metadata_source_column(self, cursor):
"""Add per-artist preferred_metadata_source column to watchlist_artists table"""
try:
cursor.execute("PRAGMA table_info(watchlist_artists)")
columns = [column[1] for column in cursor.fetchall()]
if 'preferred_metadata_source' not in columns:
cursor.execute("ALTER TABLE watchlist_artists ADD COLUMN preferred_metadata_source TEXT DEFAULT NULL")
logger.info("Added preferred_metadata_source column to watchlist_artists table")
except Exception as e:
logger.error(f"Error adding preferred_metadata_source column to watchlist_artists: {e}")
def _add_similar_artists_last_featured_column(self, cursor):
"""Add last_featured column to similar_artists for hero slider cycling"""
try:
@ -6941,7 +6956,7 @@ class MusicDatabase:
'last_scan_timestamp', 'created_at', 'updated_at']
optional_columns = ['image_url', 'itunes_artist_id', 'deezer_artist_id', 'discogs_artist_id', 'include_albums', 'include_eps', 'include_singles',
'include_live', 'include_remixes', 'include_acoustic', 'include_compilations',
'include_instrumentals', 'lookback_days']
'include_instrumentals', 'lookback_days', 'preferred_metadata_source']
columns_to_select = base_columns + [col for col in optional_columns if col in existing_columns]
@ -6977,6 +6992,7 @@ class MusicDatabase:
include_compilations = bool(row['include_compilations']) if 'include_compilations' in existing_columns else False
include_instrumentals = bool(row['include_instrumentals']) if 'include_instrumentals' in existing_columns else False
lookback_days = row['lookback_days'] if 'lookback_days' in existing_columns else None
preferred_metadata_source = row['preferred_metadata_source'] if 'preferred_metadata_source' in existing_columns else None
watchlist_artists.append(WatchlistArtist(
id=row['id'],
@ -6999,6 +7015,7 @@ class MusicDatabase:
include_compilations=include_compilations,
include_instrumentals=include_instrumentals,
lookback_days=lookback_days,
preferred_metadata_source=preferred_metadata_source,
profile_id=profile_id
))

@ -40957,7 +40957,7 @@ def watchlist_artist_config(artist_id):
include_live, include_remixes, include_acoustic, include_compilations,
artist_name, image_url, spotify_artist_id, itunes_artist_id,
last_scan_timestamp, date_added, include_instrumentals, deezer_artist_id,
lookback_days, discogs_artist_id
lookback_days, discogs_artist_id, preferred_metadata_source
FROM watchlist_artists
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?
""", (artist_id, artist_id, artist_id, artist_id))
@ -41063,8 +41063,10 @@ def watchlist_artist_config(artist_id):
'last_scan_timestamp': result[11],
'date_added': result[12],
'lookback_days': result[15] if len(result) > 15 else None,
'preferred_metadata_source': result[17] if len(result) > 17 else None,
}
from core.metadata_service import get_primary_source
return jsonify({
"success": True,
"config": config,
@ -41075,6 +41077,7 @@ def watchlist_artist_config(artist_id):
"deezer_artist_id": deezer_id,
"discogs_artist_id": discogs_id,
"watchlist_name": result[7], # Original stored watchlist artist name
"global_metadata_source": get_primary_source(),
})
else: # POST
@ -41094,6 +41097,10 @@ def watchlist_artist_config(artist_id):
# Validate lookback_days if provided
if lookback_days is not None:
lookback_days = int(lookback_days) if lookback_days != '' else None
preferred_metadata_source = data.get('preferred_metadata_source', None)
# Validate — only accept known sources, empty string means clear override
if preferred_metadata_source == '' or preferred_metadata_source not in ('spotify', 'deezer', 'itunes', 'discogs'):
preferred_metadata_source = None
# Validate at least one release type is selected
if not (include_albums or include_eps or include_singles):
@ -41116,13 +41123,13 @@ def watchlist_artist_config(artist_id):
UPDATE watchlist_artists
SET include_albums = ?, include_eps = ?, include_singles = ?,
include_live = ?, include_remixes = ?, include_acoustic = ?, include_compilations = ?,
include_instrumentals = ?, lookback_days = ?,
include_instrumentals = ?, lookback_days = ?, preferred_metadata_source = ?,
last_scan_timestamp = CASE WHEN ? THEN NULL ELSE last_scan_timestamp END,
updated_at = CURRENT_TIMESTAMP
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?
""", (int(include_albums), int(include_eps), int(include_singles),
int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations),
int(include_instrumentals), lookback_days, lookback_changed,
int(include_instrumentals), lookback_days, preferred_metadata_source, lookback_changed,
artist_id, artist_id, artist_id, artist_id))
conn.commit()

@ -7390,6 +7390,15 @@
</div>
</div>
<!-- Metadata Source Override -->
<div class="config-section">
<h3 class="config-section-title">Scan Source</h3>
<p class="config-section-subtitle">Override which metadata provider is used when scanning this artist for new releases</p>
<div class="config-metadata-source-selector" id="config-metadata-source-selector">
<!-- Dynamically populated with source buttons -->
</div>
</div>
<!-- Linked Provider Section -->
<div class="config-section" id="watchlist-linked-provider-section" style="display:none">
<h3 class="config-section-title">Linked Artist</h3>

@ -41672,6 +41672,43 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false;
document.getElementById('config-lookback-days').value = config.lookback_days != null ? String(config.lookback_days) : '';
// Populate metadata source selector
const sourceSelector = document.getElementById('config-metadata-source-selector');
if (sourceSelector) {
const sources = [
{ key: 'spotify', label: 'Spotify', id: spotify_artist_id, color: '#1DB954' },
{ key: 'deezer', label: 'Deezer', id: deezer_artist_id, color: '#A238FF' },
{ key: 'itunes', label: 'Apple Music', id: itunes_artist_id, color: '#FC3C44' },
{ key: 'discogs', label: 'Discogs', id: discogs_artist_id, color: '#333' },
];
const globalSource = data.global_metadata_source || 'deezer';
const currentOverride = config.preferred_metadata_source;
const globalLabel = { spotify: 'Spotify', deezer: 'Deezer', itunes: 'Apple Music', discogs: 'Discogs' }[globalSource] || globalSource;
let html = `<button class="config-msrc-btn ${!currentOverride ? 'active' : ''}" data-source="" title="Use global default (${globalLabel})">
<span class="config-msrc-icon">🌐</span><span class="config-msrc-label">Default (${globalLabel})</span>
</button>`;
for (const src of sources) {
if (!src.id) continue;
const isActive = currentOverride === src.key;
html += `<button class="config-msrc-btn ${isActive ? 'active' : ''}" data-source="${src.key}" style="${isActive ? 'border-color:' + src.color : ''}" title="${src.label}">
<span class="config-msrc-label">${src.label}</span>
</button>`;
}
sourceSelector.innerHTML = html;
sourceSelector.querySelectorAll('.config-msrc-btn').forEach(btn => {
btn.addEventListener('click', () => {
sourceSelector.querySelectorAll('.config-msrc-btn').forEach(b => {
b.classList.remove('active');
b.style.borderColor = '';
});
btn.classList.add('active');
const color = sources.find(s => s.key === btn.dataset.source)?.color;
if (color) btn.style.borderColor = color;
});
});
}
// Show global override notice if active
const existingNotice = document.querySelector('.global-override-notice');
if (existingNotice) existingNotice.remove();
@ -42128,6 +42165,8 @@ async function saveWatchlistArtistConfig(artistId) {
const includeInstrumentals = document.getElementById('config-include-instrumentals').checked;
const lookbackDaysVal = document.getElementById('config-lookback-days').value;
const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null;
const activeSourceBtn = document.querySelector('#config-metadata-source-selector .config-msrc-btn.active');
const preferredMetadataSource = activeSourceBtn ? (activeSourceBtn.dataset.source || null) : null;
// Validate at least one release type is selected
if (!includeAlbums && !includeEps && !includeSingles) {
@ -42156,6 +42195,7 @@ async function saveWatchlistArtistConfig(artistId) {
include_compilations: includeCompilations,
include_instrumentals: includeInstrumentals,
lookback_days: lookbackDays,
preferred_metadata_source: preferredMetadataSource,
})
});

@ -17138,6 +17138,46 @@ body.helper-mode-active #dashboard-activity-feed:hover {
margin-bottom: 24px;
}
.config-metadata-source-selector {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.config-msrc-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.config-msrc-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
}
.config-msrc-btn.active {
background: rgba(255, 255, 255, 0.1);
border-color: var(--accent-color, #1DB954);
color: #fff;
}
.config-msrc-icon {
font-size: 16px;
}
.config-msrc-label {
white-space: nowrap;
}
.config-options {
display: flex;
flex-direction: column;

Loading…
Cancel
Save