diff --git a/database/music_database.py b/database/music_database.py index 7221efc3..69efceea 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -8424,9 +8424,16 @@ class MusicDatabase: logger.error(f"Error checking similar artists freshness: {e}") return False # Default to re-fetching on error - def get_top_similar_artists(self, limit: int = 50, profile_id: int = 1, require_source: str = None) -> List[SimilarArtist]: + def get_top_similar_artists( + self, + limit: int = 50, + profile_id: int = 1, + require_source: str = None, + exclude_library_server: str = None, + ) -> List[SimilarArtist]: """Get top similar artists excluding watchlist artists, with cycling support. - require_source: if set ('spotify','itunes','deezer'), only returns artists with that source ID.""" + require_source: if set ('spotify','itunes','deezer'), only returns artists with that source ID. + exclude_library_server: if set, also excludes artists already present in that media server.""" try: with self._get_connection() as conn: cursor = conn.cursor() @@ -8440,6 +8447,27 @@ class MusicDatabase: elif require_source == 'deezer': source_filter = "AND sa.similar_artist_deezer_id IS NOT NULL AND sa.similar_artist_deezer_id != ''" + library_artist_keys = None + sql_limit = limit + if exclude_library_server: + cursor.execute(""" + SELECT name, spotify_artist_id, itunes_artist_id, deezer_id + FROM artists + WHERE server_source = ? + """, (exclude_library_server,)) + library_rows = cursor.fetchall() + library_artist_keys = { + 'spotify': {r['spotify_artist_id'] for r in library_rows if r['spotify_artist_id']}, + 'itunes': {r['itunes_artist_id'] for r in library_rows if r['itunes_artist_id']}, + 'deezer': {r['deezer_id'] for r in library_rows if r['deezer_id']}, + 'names': { + self._normalize_for_comparison(r['name']) + for r in library_rows + if r['name'] + }, + } + sql_limit = max(limit * 5, limit + 100) + cursor.execute(f""" SELECT MAX(sa.id) as id, @@ -8469,11 +8497,24 @@ class MusicDatabase: occurrence_count DESC, similarity_rank ASC LIMIT ? - """, (profile_id, profile_id, limit)) + """, (profile_id, profile_id, sql_limit)) rows = cursor.fetchall() results = [] for row in rows: + if library_artist_keys: + spotify_id = row['similar_artist_spotify_id'] + itunes_id = row['similar_artist_itunes_id'] if 'similar_artist_itunes_id' in row.keys() else None + deezer_id = row['similar_artist_deezer_id'] if 'similar_artist_deezer_id' in row.keys() else None + normalized_name = self._normalize_for_comparison(row['similar_artist_name']) + if ( + (spotify_id and spotify_id in library_artist_keys['spotify']) + or (itunes_id and itunes_id in library_artist_keys['itunes']) + or (deezer_id and deezer_id in library_artist_keys['deezer']) + or (normalized_name and normalized_name in library_artist_keys['names']) + ): + continue + genres_raw = row['genres'] if 'genres' in row.keys() else None try: genres_list = json.loads(genres_raw) if genres_raw else None @@ -8493,6 +8534,8 @@ class MusicDatabase: genres=genres_list, popularity=row['popularity'] if 'popularity' in row.keys() else 0, )) + if len(results) >= limit: + break return results except Exception as e: diff --git a/tests/discovery/test_similar_artists_library_filter.py b/tests/discovery/test_similar_artists_library_filter.py new file mode 100644 index 00000000..fc065276 --- /dev/null +++ b/tests/discovery/test_similar_artists_library_filter.py @@ -0,0 +1,86 @@ +from database.music_database import MusicDatabase + + +def _names(artists): + return {artist.similar_artist_name for artist in artists} + + +def test_top_similar_artists_can_exclude_active_server_library_artists(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + db.add_or_update_similar_artist( + source_artist_id="seed-1", + similar_artist_name="Owned By Spotify ID", + similar_artist_spotify_id="sp-owned", + profile_id=1, + ) + db.add_or_update_similar_artist( + source_artist_id="seed-1", + similar_artist_name="Owned By Deezer ID", + similar_artist_deezer_id="dz-owned", + profile_id=1, + ) + db.add_or_update_similar_artist( + source_artist_id="seed-1", + similar_artist_name="Owned By Name", + similar_artist_spotify_id="sp-owned-name", + profile_id=1, + ) + db.add_or_update_similar_artist( + source_artist_id="seed-1", + similar_artist_name="Different Server Artist", + similar_artist_spotify_id="sp-other-server", + profile_id=1, + ) + db.add_or_update_similar_artist( + source_artist_id="seed-1", + similar_artist_name="Fresh Artist", + similar_artist_spotify_id="sp-fresh", + profile_id=1, + ) + + with db._get_connection() as conn: + conn.executemany( + """ + INSERT INTO artists (name, server_source, spotify_artist_id, deezer_id) + VALUES (?, ?, ?, ?) + """, + [ + ("Library Alias", "navidrome", "sp-owned", None), + ("Library Deezer Alias", "navidrome", None, "dz-owned"), + ("owned by name", "navidrome", None, None), + ("Different Server Artist", "plex", "sp-other-server", None), + ], + ) + conn.commit() + + artists = db.get_top_similar_artists( + limit=20, + profile_id=1, + exclude_library_server="navidrome", + ) + + assert _names(artists) == {"Different Server Artist", "Fresh Artist"} + + +def test_top_similar_artists_keeps_existing_behavior_without_library_filter(tmp_path): + db = MusicDatabase(str(tmp_path / "music.db")) + db.add_or_update_similar_artist( + source_artist_id="seed-1", + similar_artist_name="Owned Artist", + similar_artist_spotify_id="sp-owned", + profile_id=1, + ) + + with db._get_connection() as conn: + conn.execute( + """ + INSERT INTO artists (name, server_source, spotify_artist_id) + VALUES (?, ?, ?) + """, + ("Owned Artist", "navidrome", "sp-owned"), + ) + conn.commit() + + artists = db.get_top_similar_artists(limit=20, profile_id=1) + + assert _names(artists) == {"Owned Artist"} diff --git a/web_server.py b/web_server.py index 47a8a653..515fae4e 100644 --- a/web_server.py +++ b/web_server.py @@ -25880,8 +25880,15 @@ def get_discover_similar_artists(): try: database = get_database() active_source = _get_active_discovery_source() + from config.settings import config_manager + active_server = config_manager.get_active_media_server() - similar_artists = database.get_top_similar_artists(limit=200, profile_id=get_current_profile_id(), require_source=active_source) + similar_artists = database.get_top_similar_artists( + limit=200, + profile_id=get_current_profile_id(), + require_source=active_source, + exclude_library_server=active_server, + ) if not similar_artists: return jsonify({"success": True, "artists": [], "source": active_source, "count": 0}) @@ -25915,7 +25922,10 @@ def get_discover_similar_artists(): artist_data["popularity"] = artist.popularity result_artists.append(artist_data) - logger.info(f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for {active_source}") + logger.info( + f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for " + f"{active_source} after excluding {active_server} library artists" + ) return jsonify({ "success": True,