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.
This commit is contained in:
parent
f749bb9604
commit
b17a6e2dd7
6 changed files with 125 additions and 5 deletions
|
|
@ -865,7 +865,14 @@ class WatchlistScanner:
|
||||||
Returns:
|
Returns:
|
||||||
WatchlistDiscographyResult or None on error
|
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)
|
result = self._resolve_watchlist_discography_for_source(watchlist_artist, source, last_scan_timestamp)
|
||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ class WatchlistArtist:
|
||||||
include_compilations: bool = False
|
include_compilations: bool = False
|
||||||
include_instrumentals: bool = False
|
include_instrumentals: bool = False
|
||||||
lookback_days: Optional[int] = None # Per-artist override; None = use global setting
|
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
|
profile_id: int = 1
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -378,6 +379,9 @@ class MusicDatabase:
|
||||||
# Add iTunes artist ID column to watchlist_artists (migration)
|
# Add iTunes artist ID column to watchlist_artists (migration)
|
||||||
self._add_watchlist_itunes_id_column(cursor)
|
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)
|
# Make spotify_artist_id nullable for iTunes-only artists (migration)
|
||||||
self._fix_watchlist_spotify_id_nullable(cursor)
|
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}")
|
logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}")
|
||||||
# Don't raise - this is a migration, database can still function
|
# 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):
|
def _add_similar_artists_last_featured_column(self, cursor):
|
||||||
"""Add last_featured column to similar_artists for hero slider cycling"""
|
"""Add last_featured column to similar_artists for hero slider cycling"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -6941,7 +6956,7 @@ class MusicDatabase:
|
||||||
'last_scan_timestamp', 'created_at', 'updated_at']
|
'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',
|
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_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]
|
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_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
|
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
|
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(
|
watchlist_artists.append(WatchlistArtist(
|
||||||
id=row['id'],
|
id=row['id'],
|
||||||
|
|
@ -6999,6 +7015,7 @@ class MusicDatabase:
|
||||||
include_compilations=include_compilations,
|
include_compilations=include_compilations,
|
||||||
include_instrumentals=include_instrumentals,
|
include_instrumentals=include_instrumentals,
|
||||||
lookback_days=lookback_days,
|
lookback_days=lookback_days,
|
||||||
|
preferred_metadata_source=preferred_metadata_source,
|
||||||
profile_id=profile_id
|
profile_id=profile_id
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40957,7 +40957,7 @@ def watchlist_artist_config(artist_id):
|
||||||
include_live, include_remixes, include_acoustic, include_compilations,
|
include_live, include_remixes, include_acoustic, include_compilations,
|
||||||
artist_name, image_url, spotify_artist_id, itunes_artist_id,
|
artist_name, image_url, spotify_artist_id, itunes_artist_id,
|
||||||
last_scan_timestamp, date_added, include_instrumentals, deezer_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
|
FROM watchlist_artists
|
||||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?
|
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?
|
||||||
""", (artist_id, artist_id, artist_id, 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],
|
'last_scan_timestamp': result[11],
|
||||||
'date_added': result[12],
|
'date_added': result[12],
|
||||||
'lookback_days': result[15] if len(result) > 15 else None,
|
'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({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"config": config,
|
"config": config,
|
||||||
|
|
@ -41075,6 +41077,7 @@ def watchlist_artist_config(artist_id):
|
||||||
"deezer_artist_id": deezer_id,
|
"deezer_artist_id": deezer_id,
|
||||||
"discogs_artist_id": discogs_id,
|
"discogs_artist_id": discogs_id,
|
||||||
"watchlist_name": result[7], # Original stored watchlist artist name
|
"watchlist_name": result[7], # Original stored watchlist artist name
|
||||||
|
"global_metadata_source": get_primary_source(),
|
||||||
})
|
})
|
||||||
|
|
||||||
else: # POST
|
else: # POST
|
||||||
|
|
@ -41094,6 +41097,10 @@ def watchlist_artist_config(artist_id):
|
||||||
# Validate lookback_days if provided
|
# Validate lookback_days if provided
|
||||||
if lookback_days is not None:
|
if lookback_days is not None:
|
||||||
lookback_days = int(lookback_days) if lookback_days != '' else 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
|
# Validate at least one release type is selected
|
||||||
if not (include_albums or include_eps or include_singles):
|
if not (include_albums or include_eps or include_singles):
|
||||||
|
|
@ -41116,13 +41123,13 @@ def watchlist_artist_config(artist_id):
|
||||||
UPDATE watchlist_artists
|
UPDATE watchlist_artists
|
||||||
SET include_albums = ?, include_eps = ?, include_singles = ?,
|
SET include_albums = ?, include_eps = ?, include_singles = ?,
|
||||||
include_live = ?, include_remixes = ?, include_acoustic = ?, include_compilations = ?,
|
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,
|
last_scan_timestamp = CASE WHEN ? THEN NULL ELSE last_scan_timestamp END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE spotify_artist_id = ? OR itunes_artist_id = ? OR deezer_artist_id = ? OR discogs_artist_id = ?
|
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_albums), int(include_eps), int(include_singles),
|
||||||
int(include_live), int(include_remixes), int(include_acoustic), int(include_compilations),
|
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))
|
artist_id, artist_id, artist_id, artist_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7390,6 +7390,15 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Linked Provider Section -->
|
||||||
<div class="config-section" id="watchlist-linked-provider-section" style="display:none">
|
<div class="config-section" id="watchlist-linked-provider-section" style="display:none">
|
||||||
<h3 class="config-section-title">Linked Artist</h3>
|
<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-include-instrumentals').checked = config.include_instrumentals || false;
|
||||||
document.getElementById('config-lookback-days').value = config.lookback_days != null ? String(config.lookback_days) : '';
|
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
|
// Show global override notice if active
|
||||||
const existingNotice = document.querySelector('.global-override-notice');
|
const existingNotice = document.querySelector('.global-override-notice');
|
||||||
if (existingNotice) existingNotice.remove();
|
if (existingNotice) existingNotice.remove();
|
||||||
|
|
@ -42128,6 +42165,8 @@ async function saveWatchlistArtistConfig(artistId) {
|
||||||
const includeInstrumentals = document.getElementById('config-include-instrumentals').checked;
|
const includeInstrumentals = document.getElementById('config-include-instrumentals').checked;
|
||||||
const lookbackDaysVal = document.getElementById('config-lookback-days').value;
|
const lookbackDaysVal = document.getElementById('config-lookback-days').value;
|
||||||
const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null;
|
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
|
// Validate at least one release type is selected
|
||||||
if (!includeAlbums && !includeEps && !includeSingles) {
|
if (!includeAlbums && !includeEps && !includeSingles) {
|
||||||
|
|
@ -42156,6 +42195,7 @@ async function saveWatchlistArtistConfig(artistId) {
|
||||||
include_compilations: includeCompilations,
|
include_compilations: includeCompilations,
|
||||||
include_instrumentals: includeInstrumentals,
|
include_instrumentals: includeInstrumentals,
|
||||||
lookback_days: lookbackDays,
|
lookback_days: lookbackDays,
|
||||||
|
preferred_metadata_source: preferredMetadataSource,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17138,6 +17138,46 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
margin-bottom: 24px;
|
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 {
|
.config-options {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue