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.
This commit is contained in:
Broque Thomas 2026-02-24 18:54:21 -08:00
parent f90d6567f7
commit 7261b04950
2 changed files with 64 additions and 19 deletions

View file

@ -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:

View file

@ -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: