Improve watchlist cross-provider matching accuracy and add manual artist linking UI
This commit is contained in:
parent
0e89155c15
commit
32f1cc946c
5 changed files with 592 additions and 21 deletions
|
|
@ -841,8 +841,9 @@ class WatchlistScanner:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"🔄 Backfilling {len(artists_to_match)} artists with {provider} IDs...")
|
logger.info(f"🔄 Backfilling {len(artists_to_match)} artists with {provider} IDs...")
|
||||||
|
|
||||||
matched_count = 0
|
matched_count = 0
|
||||||
|
unmatched_names = []
|
||||||
for artist in artists_to_match:
|
for artist in artists_to_match:
|
||||||
try:
|
try:
|
||||||
if provider == 'spotify':
|
if provider == 'spotify':
|
||||||
|
|
@ -852,7 +853,9 @@ class WatchlistScanner:
|
||||||
artist.spotify_artist_id = new_id # Update in memory
|
artist.spotify_artist_id = new_id # Update in memory
|
||||||
matched_count += 1
|
matched_count += 1
|
||||||
logger.info(f"✅ Matched '{artist.artist_name}' to Spotify: {new_id}")
|
logger.info(f"✅ Matched '{artist.artist_name}' to Spotify: {new_id}")
|
||||||
|
else:
|
||||||
|
unmatched_names.append(artist.artist_name)
|
||||||
|
|
||||||
elif provider == 'itunes':
|
elif provider == 'itunes':
|
||||||
new_id = self._match_to_itunes(artist.artist_name)
|
new_id = self._match_to_itunes(artist.artist_name)
|
||||||
if new_id:
|
if new_id:
|
||||||
|
|
@ -860,42 +863,114 @@ class WatchlistScanner:
|
||||||
artist.itunes_artist_id = new_id # Update in memory
|
artist.itunes_artist_id = new_id # Update in memory
|
||||||
matched_count += 1
|
matched_count += 1
|
||||||
logger.info(f"✅ Matched '{artist.artist_name}' to iTunes: {new_id}")
|
logger.info(f"✅ Matched '{artist.artist_name}' to iTunes: {new_id}")
|
||||||
|
else:
|
||||||
|
unmatched_names.append(artist.artist_name)
|
||||||
|
|
||||||
# Small delay to avoid API rate limits
|
# Small delay to avoid API rate limits
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not match '{artist.artist_name}' to {provider}: {e}")
|
logger.warning(f"Could not match '{artist.artist_name}' to {provider}: {e}")
|
||||||
|
unmatched_names.append(artist.artist_name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"✅ Backfilled {matched_count}/{len(artists_to_match)} artists with {provider} IDs")
|
logger.info(f"✅ Backfilled {matched_count}/{len(artists_to_match)} artists with {provider} IDs")
|
||||||
|
if unmatched_names:
|
||||||
|
logger.warning(f"⚠️ Could not confidently match {len(unmatched_names)} artists: {', '.join(unmatched_names[:10])}"
|
||||||
|
f"{'...' if len(unmatched_names) > 10 else ''} — use Watchlist Settings to link manually")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_artist_name(name: str) -> str:
|
||||||
|
"""Normalize artist name for comparison."""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
s = name.lower().strip()
|
||||||
|
# Remove "the " prefix
|
||||||
|
s = re.sub(r'^the\s+', '', s)
|
||||||
|
# Remove non-alphanumeric except spaces
|
||||||
|
s = re.sub(r'[^\w\s]', '', s)
|
||||||
|
# Collapse whitespace
|
||||||
|
s = re.sub(r'\s+', ' ', s).strip()
|
||||||
|
return s
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _artist_name_similarity(name_a: str, name_b: str) -> float:
|
||||||
|
"""Calculate similarity between two artist names (0.0-1.0)."""
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
na = WatchlistScanner._normalize_artist_name(name_a)
|
||||||
|
nb = WatchlistScanner._normalize_artist_name(name_b)
|
||||||
|
if not na or not nb:
|
||||||
|
return 0.0
|
||||||
|
if na == nb:
|
||||||
|
return 1.0
|
||||||
|
return SequenceMatcher(None, na, nb).ratio()
|
||||||
|
|
||||||
|
def _best_artist_match(self, results, artist_name: str) -> Optional[str]:
|
||||||
|
"""Pick the best matching artist from search results using name similarity.
|
||||||
|
|
||||||
|
Returns the artist ID only if we're confident it's the right match.
|
||||||
|
"""
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Exact normalized match gets immediate acceptance
|
||||||
|
for r in results:
|
||||||
|
if self._normalize_artist_name(r.name) == self._normalize_artist_name(artist_name):
|
||||||
|
logger.info(f" Exact match: '{r.name}' (id={r.id})")
|
||||||
|
return r.id
|
||||||
|
|
||||||
|
# Score all results by name similarity + popularity bonus
|
||||||
|
candidates = []
|
||||||
|
for r in results:
|
||||||
|
sim = self._artist_name_similarity(artist_name, r.name)
|
||||||
|
# Small popularity bonus (max 0.05) to break ties between similar names
|
||||||
|
pop_bonus = (getattr(r, 'popularity', 0) / 100) * 0.05
|
||||||
|
score = sim + pop_bonus
|
||||||
|
candidates.append((r, sim, score))
|
||||||
|
logger.debug(f" Candidate: '{r.name}' sim={sim:.2f} pop={getattr(r, 'popularity', 0)} score={score:.3f}")
|
||||||
|
|
||||||
|
# Sort by score descending
|
||||||
|
candidates.sort(key=lambda x: x[2], reverse=True)
|
||||||
|
best, best_sim, best_score = candidates[0]
|
||||||
|
|
||||||
|
# Require high similarity to accept (0.85 threshold)
|
||||||
|
if best_sim >= 0.85:
|
||||||
|
logger.info(f" Best match: '{best.name}' (sim={best_sim:.2f}, id={best.id})")
|
||||||
|
return best.id
|
||||||
|
|
||||||
|
# Between 0.70-0.85: accept only if it's clearly better than runner-up
|
||||||
|
if best_sim >= 0.70 and len(candidates) > 1:
|
||||||
|
runner_up_sim = candidates[1][1]
|
||||||
|
if best_sim - runner_up_sim >= 0.15:
|
||||||
|
logger.info(f" Best match (clear winner): '{best.name}' (sim={best_sim:.2f}, id={best.id})")
|
||||||
|
return best.id
|
||||||
|
|
||||||
|
logger.warning(f" No confident match for '{artist_name}' — best was '{best.name}' (sim={best_sim:.2f})")
|
||||||
|
return None
|
||||||
|
|
||||||
def _match_to_spotify(self, artist_name: str) -> Optional[str]:
|
def _match_to_spotify(self, artist_name: str) -> Optional[str]:
|
||||||
"""Match artist name to Spotify ID"""
|
"""Match artist name to Spotify ID using fuzzy name comparison."""
|
||||||
try:
|
try:
|
||||||
# Use metadata service if available, fallback to spotify_client
|
|
||||||
if hasattr(self, '_metadata_service') and self._metadata_service:
|
if hasattr(self, '_metadata_service') and self._metadata_service:
|
||||||
results = self._metadata_service.spotify.search_artists(artist_name, limit=1)
|
results = self._metadata_service.spotify.search_artists(artist_name, limit=5)
|
||||||
else:
|
else:
|
||||||
results = self.spotify_client.search_artists(artist_name, limit=1)
|
results = self.spotify_client.search_artists(artist_name, limit=5)
|
||||||
|
|
||||||
if results:
|
return self._best_artist_match(results, artist_name)
|
||||||
return results[0].id
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not match {artist_name} to Spotify: {e}")
|
logger.warning(f"Could not match {artist_name} to Spotify: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _match_to_itunes(self, artist_name: str) -> Optional[str]:
|
def _match_to_itunes(self, artist_name: str) -> Optional[str]:
|
||||||
"""Match artist name to iTunes ID"""
|
"""Match artist name to iTunes ID using fuzzy name comparison."""
|
||||||
try:
|
try:
|
||||||
# Use metadata service's iTunes client
|
|
||||||
if hasattr(self, '_metadata_service') and self._metadata_service:
|
if hasattr(self, '_metadata_service') and self._metadata_service:
|
||||||
results = self._metadata_service.itunes.search_artists(artist_name, limit=1)
|
results = self._metadata_service.itunes.search_artists(artist_name, limit=5)
|
||||||
if results:
|
|
||||||
return results[0].id
|
|
||||||
else:
|
else:
|
||||||
# iTunes client not available without metadata service
|
|
||||||
logger.warning(f"Cannot match to iTunes - MetadataService not available")
|
logger.warning(f"Cannot match to iTunes - MetadataService not available")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._best_artist_match(results, artist_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not match {artist_name} to iTunes: {e}")
|
logger.warning(f"Could not match {artist_name} to iTunes: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -29050,7 +29050,10 @@ def watchlist_artist_config(artist_id):
|
||||||
"success": True,
|
"success": True,
|
||||||
"config": config,
|
"config": config,
|
||||||
"artist": artist_info,
|
"artist": artist_info,
|
||||||
"recent_releases": releases
|
"recent_releases": releases,
|
||||||
|
"spotify_artist_id": spotify_id,
|
||||||
|
"itunes_artist_id": itunes_id,
|
||||||
|
"watchlist_name": result[7], # Original stored watchlist artist name
|
||||||
})
|
})
|
||||||
|
|
||||||
else: # POST
|
else: # POST
|
||||||
|
|
@ -29112,6 +29115,74 @@ def watchlist_artist_config(artist_id):
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/watchlist/artist/<artist_id>/link-provider', methods=['POST'])
|
||||||
|
def watchlist_artist_link_provider(artist_id):
|
||||||
|
"""Manually link a watchlist artist to a different Spotify/iTunes artist."""
|
||||||
|
try:
|
||||||
|
from database.music_database import get_database
|
||||||
|
database = get_database()
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
if not data:
|
||||||
|
return jsonify({"success": False, "error": "No data provided"}), 400
|
||||||
|
|
||||||
|
new_provider_id = data.get('provider_id', '').strip()
|
||||||
|
provider = data.get('provider', '').strip() # 'spotify' or 'itunes'
|
||||||
|
|
||||||
|
if not new_provider_id or provider not in ('spotify', 'itunes'):
|
||||||
|
return jsonify({"success": False, "error": "Missing provider or provider_id"}), 400
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(database.database_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Find the watchlist artist row
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT id, artist_name, spotify_artist_id, itunes_artist_id
|
||||||
|
FROM watchlist_artists
|
||||||
|
WHERE spotify_artist_id = ? OR itunes_artist_id = ?
|
||||||
|
""", (artist_id, artist_id))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"success": False, "error": "Artist not found in watchlist"}), 404
|
||||||
|
|
||||||
|
watchlist_row_id = row[0]
|
||||||
|
artist_name = row[1]
|
||||||
|
|
||||||
|
# Check for duplicate — another watchlist artist already has this provider ID
|
||||||
|
col = 'spotify_artist_id' if provider == 'spotify' else 'itunes_artist_id'
|
||||||
|
cursor.execute(f"SELECT id, artist_name FROM watchlist_artists WHERE {col} = ? AND id != ?",
|
||||||
|
(new_provider_id, watchlist_row_id))
|
||||||
|
duplicate = cursor.fetchone()
|
||||||
|
if duplicate:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({"success": False, "error": f"Another watchlist artist ('{duplicate[1]}') already has this {provider} ID"}), 409
|
||||||
|
|
||||||
|
if provider == 'spotify':
|
||||||
|
cursor.execute("UPDATE watchlist_artists SET spotify_artist_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
(new_provider_id, watchlist_row_id))
|
||||||
|
else:
|
||||||
|
cursor.execute("UPDATE watchlist_artists SET itunes_artist_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||||
|
(new_provider_id, watchlist_row_id))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
print(f"✅ Manually linked watchlist artist '{artist_name}' to {provider} ID: {new_provider_id}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"message": f"Linked to {provider} artist successfully",
|
||||||
|
"new_provider_id": new_provider_id
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error linking watchlist artist provider: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/watchlist/global-config', methods=['GET', 'POST'])
|
@app.route('/api/watchlist/global-config', methods=['GET', 'POST'])
|
||||||
def watchlist_global_config():
|
def watchlist_global_config():
|
||||||
"""Get or update global watchlist configuration (overrides per-artist settings)"""
|
"""Get or update global watchlist configuration (overrides per-artist settings)"""
|
||||||
|
|
|
||||||
|
|
@ -5075,6 +5075,15 @@
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
<p class="config-section-subtitle">The metadata provider artist linked to this watchlist entry</p>
|
||||||
|
<div id="watchlist-linked-provider-content">
|
||||||
|
<!-- Dynamically populated -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="watchlist-artist-config-footer">
|
<div class="watchlist-artist-config-footer">
|
||||||
|
|
|
||||||
|
|
@ -32042,6 +32042,188 @@ function closeWatchlistModal() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populate the linked provider section in the watchlist config modal.
|
||||||
|
* Shows which Spotify/iTunes artist is linked and allows changing it.
|
||||||
|
*/
|
||||||
|
function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo) {
|
||||||
|
const section = document.getElementById('watchlist-linked-provider-section');
|
||||||
|
const content = document.getElementById('watchlist-linked-provider-content');
|
||||||
|
if (!section || !content) return;
|
||||||
|
|
||||||
|
// Determine which providers are linked
|
||||||
|
const hasSpotify = !!spotifyId;
|
||||||
|
const hasItunes = !!itunesId;
|
||||||
|
|
||||||
|
if (!hasSpotify && !hasItunes) {
|
||||||
|
section.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.style.display = '';
|
||||||
|
|
||||||
|
// Build linked artist display — show a card for each linked provider
|
||||||
|
// The artist info from the API is for the currently active provider
|
||||||
|
const linkedName = artistInfo?.name || artistName;
|
||||||
|
const linkedImage = artistInfo?.image_url || '';
|
||||||
|
const nameMatches = linkedName.toLowerCase().trim() === artistName.toLowerCase().trim();
|
||||||
|
|
||||||
|
let html = `<div class="watchlist-linked-artist-card">`;
|
||||||
|
|
||||||
|
if (linkedImage) {
|
||||||
|
html += `<img src="${linkedImage}" alt="" class="watchlist-linked-artist-img">`;
|
||||||
|
} else {
|
||||||
|
html += `<div class="watchlist-linked-artist-img-placeholder">🎵</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `<div class="watchlist-linked-artist-info">`;
|
||||||
|
html += `<div class="watchlist-linked-artist-name">${escapeHtml(linkedName)}</div>`;
|
||||||
|
|
||||||
|
// Show provider badges
|
||||||
|
html += `<div class="watchlist-linked-artist-providers">`;
|
||||||
|
if (hasSpotify) {
|
||||||
|
html += `<span class="watchlist-provider-badge spotify">Spotify</span>`;
|
||||||
|
}
|
||||||
|
if (hasItunes) {
|
||||||
|
html += `<span class="watchlist-provider-badge itunes">iTunes</span>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
|
||||||
|
// Show mismatch warning if linked name differs from watchlist name
|
||||||
|
if (!nameMatches) {
|
||||||
|
html += `<div class="watchlist-linked-mismatch-warning">
|
||||||
|
⚠️ Name differs from watchlist entry "<strong>${escapeHtml(artistName)}</strong>"
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`; // close info
|
||||||
|
|
||||||
|
html += `<button class="watchlist-linked-change-btn" id="watchlist-linked-change-btn" title="Change linked artist">Change</button>`;
|
||||||
|
html += `</div>`; // close card
|
||||||
|
|
||||||
|
// Search UI (hidden by default)
|
||||||
|
html += `<div class="watchlist-linked-search" id="watchlist-linked-search" style="display:none">
|
||||||
|
<div class="watchlist-linked-search-input-row">
|
||||||
|
<input type="text" id="watchlist-linked-search-input" class="watchlist-linked-search-input"
|
||||||
|
placeholder="Search for the correct artist..." value="${escapeHtml(artistName)}">
|
||||||
|
<button class="watchlist-linked-search-btn" id="watchlist-linked-search-go">Search</button>
|
||||||
|
</div>
|
||||||
|
<div class="watchlist-linked-search-results" id="watchlist-linked-search-results"></div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
content.innerHTML = html;
|
||||||
|
|
||||||
|
// Wire up Change button
|
||||||
|
document.getElementById('watchlist-linked-change-btn').onclick = () => {
|
||||||
|
document.getElementById('watchlist-linked-search').style.display = '';
|
||||||
|
const input = document.getElementById('watchlist-linked-search-input');
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wire up search
|
||||||
|
const doSearch = () => _searchLinkedProviderArtists(artistId, artistName);
|
||||||
|
document.getElementById('watchlist-linked-search-go').onclick = doSearch;
|
||||||
|
document.getElementById('watchlist-linked-search-input').onkeydown = (e) => {
|
||||||
|
if (e.key === 'Enter') doSearch();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search for artists to link to a watchlist entry.
|
||||||
|
*/
|
||||||
|
async function _searchLinkedProviderArtists(currentArtistId, watchlistName) {
|
||||||
|
const input = document.getElementById('watchlist-linked-search-input');
|
||||||
|
const resultsContainer = document.getElementById('watchlist-linked-search-results');
|
||||||
|
const query = input?.value?.trim();
|
||||||
|
if (!query || !resultsContainer) return;
|
||||||
|
|
||||||
|
resultsContainer.innerHTML = '<div style="padding:12px;color:#888;text-align:center">Searching...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use match/search to find artists (returns genres, popularity, image)
|
||||||
|
const response = await fetch('/api/match/search', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query: query, context: 'artist' })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const results = data.results || [];
|
||||||
|
if (results.length === 0) {
|
||||||
|
resultsContainer.innerHTML = '<div style="padding:12px;color:#888;text-align:center">No artists found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (const r of results.slice(0, 8)) {
|
||||||
|
const a = r.artist || r;
|
||||||
|
const img = a.image_url || '';
|
||||||
|
const genres = (a.genres || []).slice(0, 2).join(', ');
|
||||||
|
const pop = a.popularity || 0;
|
||||||
|
html += `<div class="watchlist-linked-search-result" data-id="${a.id}" data-name="${escapeHtml(a.name)}">
|
||||||
|
${img ? `<img src="${img}" alt="" class="watchlist-linked-result-img">` :
|
||||||
|
`<div class="watchlist-linked-result-img-placeholder">🎵</div>`}
|
||||||
|
<div class="watchlist-linked-result-info">
|
||||||
|
<div class="watchlist-linked-result-name">${escapeHtml(a.name)}</div>
|
||||||
|
<div class="watchlist-linked-result-meta">${genres ? escapeHtml(genres) + ' · ' : ''}Pop: ${pop}</div>
|
||||||
|
</div>
|
||||||
|
<button class="watchlist-linked-select-btn">Select</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsContainer.innerHTML = html;
|
||||||
|
|
||||||
|
// Wire up select buttons
|
||||||
|
resultsContainer.querySelectorAll('.watchlist-linked-search-result').forEach(el => {
|
||||||
|
el.querySelector('.watchlist-linked-select-btn').onclick = async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const newId = el.dataset.id;
|
||||||
|
const newName = el.dataset.name;
|
||||||
|
await _linkProviderArtist(currentArtistId, newId, newName);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error searching for linked artist:', err);
|
||||||
|
resultsContainer.innerHTML = '<div style="padding:12px;color:#f44;text-align:center">Search error</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link a watchlist artist to a new provider artist.
|
||||||
|
*/
|
||||||
|
async function _linkProviderArtist(currentArtistId, newProviderId, newProviderName) {
|
||||||
|
// Determine provider type from ID format
|
||||||
|
const provider = /^\d+$/.test(newProviderId) ? 'itunes' : 'spotify';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/watchlist/artist/${currentArtistId}/link-provider`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ provider_id: newProviderId, provider: provider })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success) {
|
||||||
|
showToast(`Failed to link artist: ${data.error}`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(`Linked to "${newProviderName}" on ${provider}`, 'success');
|
||||||
|
|
||||||
|
// Close and reopen the config modal with the new ID to refresh everything
|
||||||
|
closeWatchlistArtistConfigModal();
|
||||||
|
setTimeout(() => {
|
||||||
|
openWatchlistArtistConfigModal(newProviderId, newProviderName);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error linking provider artist:', err);
|
||||||
|
showToast('Failed to link artist', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open watchlist artist configuration modal
|
* Open watchlist artist configuration modal
|
||||||
* @param {string} artistId - Spotify artist ID
|
* @param {string} artistId - Spotify artist ID
|
||||||
|
|
@ -32061,7 +32243,10 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { config, artist } = data;
|
const { config, artist, spotify_artist_id, itunes_artist_id, watchlist_name } = data;
|
||||||
|
|
||||||
|
// Populate linked provider section (use DB watchlist_name for mismatch comparison)
|
||||||
|
_populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist);
|
||||||
|
|
||||||
// Check if global override is active
|
// Check if global override is active
|
||||||
let globalOverrideActive = false;
|
let globalOverrideActive = false;
|
||||||
|
|
@ -32173,6 +32358,12 @@ function closeWatchlistArtistConfigModal() {
|
||||||
if (heroContainer) {
|
if (heroContainer) {
|
||||||
heroContainer.innerHTML = '';
|
heroContainer.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear linked provider section
|
||||||
|
const linkedContent = document.getElementById('watchlist-linked-provider-content');
|
||||||
|
if (linkedContent) linkedContent.innerHTML = '';
|
||||||
|
const linkedSection = document.getElementById('watchlist-linked-provider-section');
|
||||||
|
if (linkedSection) linkedSection.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -13191,6 +13191,231 @@ body {
|
||||||
border-color: rgba(255, 255, 255, 0.25);
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Linked Provider Artist */
|
||||||
|
.watchlist-linked-artist-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-artist-img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-artist-img-placeholder {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-artist-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-artist-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-artist-providers {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-provider-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-provider-badge.spotify {
|
||||||
|
background: rgba(30, 215, 96, 0.15);
|
||||||
|
color: #1ed760;
|
||||||
|
border: 1px solid rgba(30, 215, 96, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-provider-badge.itunes {
|
||||||
|
background: rgba(252, 60, 68, 0.15);
|
||||||
|
color: #fc3c44;
|
||||||
|
border: 1px solid rgba(252, 60, 68, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-mismatch-warning {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #ffc107;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-mismatch-warning strong {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-change-btn {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ccc;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-change-btn:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Linked Provider Search */
|
||||||
|
.watchlist-linked-search {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-search-input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-search-input:focus {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-search-btn {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(var(--accent-rgb), 0.2);
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-search-btn:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-search-results {
|
||||||
|
margin-top: 8px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-search-result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-search-result:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-result-img {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-result-img-placeholder {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-result-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-result-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-result-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-select-btn {
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
background: rgba(var(--accent-rgb), 0.15);
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.watchlist-linked-select-btn:hover {
|
||||||
|
background: rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile Responsive */
|
/* Mobile Responsive */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.watchlist-artist-config-modal {
|
.watchlist-artist-config-modal {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue