diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index b10ebc80..6c6b7a95 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -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 diff --git a/web_server.py b/web_server.py index 39746915..e9cdf157 100644 --- a/web_server.py +++ b/web_server.py @@ -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//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)""" diff --git a/webui/index.html b/webui/index.html index 6a479d9e..83864f54 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5075,6 +5075,15 @@ + + +