Improve watchlist cross-provider matching accuracy and add manual artist linking UI

pull/253/head
Broque Thomas 2 months ago
parent 0e89155c15
commit 32f1cc946c

@ -841,8 +841,9 @@ class WatchlistScanner:
return
logger.info(f"🔄 Backfilling {len(artists_to_match)} artists with {provider} IDs...")
matched_count = 0
unmatched_names = []
for artist in artists_to_match:
try:
if provider == 'spotify':
@ -852,7 +853,9 @@ class WatchlistScanner:
artist.spotify_artist_id = new_id # Update in memory
matched_count += 1
logger.info(f"✅ Matched '{artist.artist_name}' to Spotify: {new_id}")
else:
unmatched_names.append(artist.artist_name)
elif provider == 'itunes':
new_id = self._match_to_itunes(artist.artist_name)
if new_id:
@ -860,42 +863,114 @@ class WatchlistScanner:
artist.itunes_artist_id = new_id # Update in memory
matched_count += 1
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
time.sleep(0.3)
except Exception as e:
logger.warning(f"Could not match '{artist.artist_name}' to {provider}: {e}")
unmatched_names.append(artist.artist_name)
continue
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]:
"""Match artist name to Spotify ID"""
"""Match artist name to Spotify ID using fuzzy name comparison."""
try:
# Use metadata service if available, fallback to spotify_client
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:
results = self.spotify_client.search_artists(artist_name, limit=1)
if results:
return results[0].id
results = self.spotify_client.search_artists(artist_name, limit=5)
return self._best_artist_match(results, artist_name)
except Exception as e:
logger.warning(f"Could not match {artist_name} to Spotify: {e}")
return None
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:
# Use metadata service's iTunes client
if hasattr(self, '_metadata_service') and self._metadata_service:
results = self._metadata_service.itunes.search_artists(artist_name, limit=1)
if results:
return results[0].id
results = self._metadata_service.itunes.search_artists(artist_name, limit=5)
else:
# iTunes client not available without metadata service
logger.warning(f"Cannot match to iTunes - MetadataService not available")
return None
return self._best_artist_match(results, artist_name)
except Exception as e:
logger.warning(f"Could not match {artist_name} to iTunes: {e}")
return None

@ -29050,7 +29050,10 @@ def watchlist_artist_config(artist_id):
"success": True,
"config": config,
"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
@ -29112,6 +29115,74 @@ def watchlist_artist_config(artist_id):
traceback.print_exc()
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'])
def watchlist_global_config():
"""Get or update global watchlist configuration (overrides per-artist settings)"""

@ -5075,6 +5075,15 @@
</label>
</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 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
* @param {string} artistId - Spotify artist ID
@ -32061,7 +32243,10 @@ async function openWatchlistArtistConfigModal(artistId, artistName) {
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
let globalOverrideActive = false;
@ -32173,6 +32358,12 @@ function closeWatchlistArtistConfigModal() {
if (heroContainer) {
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);
}
/* 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 */
@media (max-width: 640px) {
.watchlist-artist-config-modal {

Loading…
Cancel
Save