From 7261b04950dbfe7a21b5d285f87fb339bc6e9863 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Tue, 24 Feb 2026 18:54:21 -0800 Subject: [PATCH] Add hero cycling for similar artists Add support for cycling hero/featured similar artists by introducing a last_featured timestamp and using it to prefer least-recently-featured artists. Changes include: - DB migration: add _add_similar_artists_last_featured_column and call it during migrations to add a last_featured TIMESTAMP column (non-fatal on error). - Query changes: get_top_similar_artists now excludes watchlist artists via a LEFT JOIN and wa.id IS NULL and orders results by last_featured (nulls first), then by last_featured asc, occurrence_count desc, and similarity_rank asc. The query aliasing was also added for clarity. - New helper: mark_artists_featured updates similar_artists.last_featured = CURRENT_TIMESTAMP for shown artists. - Web server: increase fetch limit to 50, remove random shuffle and instead take the top 10 (already ordered by cycling logic), and call mark_artists_featured to rotate featured artists. These changes aim to provide deterministic, least-recently-shown cycling of hero artists while keeping watchlist artists out of recommendations. --- database/music_database.py | 68 +++++++++++++++++++++++++++++++------- web_server.py | 15 +++++---- 2 files changed, 64 insertions(+), 19 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index 838bfe8a..7f0838da 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -325,6 +325,9 @@ class MusicDatabase: ) """) + # Add last_featured column to similar_artists for hero cycling (migration) + self._add_similar_artists_last_featured_column(cursor) + # Retag tool tables for tracking processed downloads (migration) self._add_retag_tables(cursor) @@ -877,6 +880,20 @@ class MusicDatabase: logger.error(f"Error adding itunes_artist_id column to watchlist_artists: {e}") # Don't raise - this is a migration, database can still function + def _add_similar_artists_last_featured_column(self, cursor): + """Add last_featured column to similar_artists for hero slider cycling""" + try: + cursor.execute("PRAGMA table_info(similar_artists)") + columns = [column[1] for column in cursor.fetchall()] + + if 'last_featured' not in columns: + cursor.execute("ALTER TABLE similar_artists ADD COLUMN last_featured TIMESTAMP") + logger.info("Added last_featured column to similar_artists table for hero cycling") + + except Exception as e: + logger.error(f"Error adding last_featured column to similar_artists: {e}") + # Don't raise - this is a migration, database can still function + def _fix_watchlist_spotify_id_nullable(self, cursor): """ Make spotify_artist_id nullable in watchlist_artists table. @@ -4124,24 +4141,34 @@ class MusicDatabase: return False # Default to re-fetching on error def get_top_similar_artists(self, limit: int = 50) -> List[SimilarArtist]: - """Get top similar artists across all watchlist artists, ordered by occurrence count""" + """Get top similar artists excluding watchlist artists, with cycling support""" try: with self._get_connection() as conn: cursor = conn.cursor() cursor.execute(""" SELECT - MAX(id) as id, - MAX(source_artist_id) as source_artist_id, - MAX(similar_artist_spotify_id) as similar_artist_spotify_id, - MAX(similar_artist_itunes_id) as similar_artist_itunes_id, - similar_artist_name, - AVG(similarity_rank) as similarity_rank, - SUM(occurrence_count) as occurrence_count, - MAX(last_updated) as last_updated - FROM similar_artists - GROUP BY similar_artist_name - ORDER BY occurrence_count DESC, similarity_rank ASC + MAX(sa.id) as id, + MAX(sa.source_artist_id) as source_artist_id, + MAX(sa.similar_artist_spotify_id) as similar_artist_spotify_id, + MAX(sa.similar_artist_itunes_id) as similar_artist_itunes_id, + sa.similar_artist_name, + AVG(sa.similarity_rank) as similarity_rank, + SUM(sa.occurrence_count) as occurrence_count, + MAX(sa.last_updated) as last_updated + FROM similar_artists sa + LEFT JOIN watchlist_artists wa ON ( + (sa.similar_artist_spotify_id IS NOT NULL AND sa.similar_artist_spotify_id = wa.spotify_artist_id) + OR (sa.similar_artist_itunes_id IS NOT NULL AND sa.similar_artist_itunes_id = wa.itunes_artist_id) + OR LOWER(sa.similar_artist_name) = LOWER(wa.artist_name) + ) + WHERE wa.id IS NULL + GROUP BY sa.similar_artist_name + ORDER BY + CASE WHEN MAX(sa.last_featured) IS NULL THEN 0 ELSE 1 END, + MAX(sa.last_featured) ASC, + occurrence_count DESC, + similarity_rank ASC LIMIT ? """, (limit,)) @@ -4161,6 +4188,23 @@ class MusicDatabase: logger.error(f"Error getting top similar artists: {e}") return [] + def mark_artists_featured(self, artist_names: List[str]): + """Update last_featured timestamp for artists shown in the hero slider""" + if not artist_names: + return + try: + with self._get_connection() as conn: + cursor = conn.cursor() + placeholders = ','.join('?' for _ in artist_names) + cursor.execute(f""" + UPDATE similar_artists + SET last_featured = CURRENT_TIMESTAMP + WHERE similar_artist_name IN ({placeholders}) + """, artist_names) + conn.commit() + except Exception as e: + logger.error(f"Error marking artists as featured: {e}") + def add_to_discovery_pool(self, track_data: Dict[str, Any], source: str = 'spotify') -> bool: """Add a track to the discovery pool (supports both Spotify and iTunes sources)""" try: diff --git a/web_server.py b/web_server.py index 2963d7af..f30d8c95 100644 --- a/web_server.py +++ b/web_server.py @@ -22031,8 +22031,8 @@ def get_discover_hero(): from core.itunes_client import iTunesClient itunes_client = iTunesClient() - # Get top similar artists (by occurrence count) - get 20 for variety - similar_artists = database.get_top_similar_artists(limit=20) + # Get top similar artists (excluding watchlist, cycled by last_featured) + similar_artists = database.get_top_similar_artists(limit=50) # FALLBACK: If no similar artists exist, use watchlist artists for Hero section if not similar_artists: @@ -22129,11 +22129,8 @@ def get_discover_hero(): print(f"[Discover Hero] Found {len(valid_artists)} valid artists for source: {active_source}") - # Shuffle for variety and take top 10 - import random - shuffled = list(valid_artists) - random.shuffle(shuffled) - similar_artists = shuffled[:10] + # Take top 10 (already ordered by least-recently-featured, then quality) + similar_artists = valid_artists[:10] # Convert to JSON format with data enrichment from appropriate source hero_artists = [] @@ -22178,6 +22175,10 @@ def get_discover_hero(): hero_artists.append(artist_data) + # Mark these artists as featured so they cycle to the back of the queue + featured_names = [a["artist_name"] for a in hero_artists] + database.mark_artists_featured(featured_names) + return jsonify({"success": True, "artists": hero_artists, "source": active_source}) except Exception as e: