Personalize discovery playlists using listening stats
Integrates play history data into the discovery algorithm: - Listening profile: _get_listening_profile() builds user's top artists, genres, play counts, and listening velocity from the last 30 days - Artist genre cache: pre-built from local DB for O(1) genre lookups - Release Radar: +10 genre affinity, +15 artist familiarity, -10 overplay penalty. Weights rebalanced to 45% recency + 25% popularity + bonuses - Discovery Weekly: serendipity scoring within tiers — boosts unheard artists in preferred genres, penalizes overplayed artists - Recent Albums: adaptive time window (21-60 days) based on listening velocity — heavy listeners get fresher content, casual listeners more - New "Because You Listen To" sections: personalized carousels based on user's top 3 played artists via similar artists + genre fallback - New endpoint: /api/discover/because-you-listen-to with artist images - Frontend: BYLT sections with artist photo headers on discover page - All changes gracefully fall back when no listening data exists
This commit is contained in:
parent
cfb0e85564
commit
232481fd13
4 changed files with 368 additions and 7 deletions
|
|
@ -2438,8 +2438,19 @@ class WatchlistScanner:
|
|||
# Clear existing cache for this profile
|
||||
self.database.clear_discovery_recent_albums(profile_id=profile_id)
|
||||
|
||||
# 30-day window for recent releases
|
||||
cutoff_date = datetime.now() - timedelta(days=30)
|
||||
# Adaptive window based on listening velocity
|
||||
days_lookback = 30
|
||||
try:
|
||||
profile = self._get_listening_profile(profile_id)
|
||||
if profile['has_data']:
|
||||
if profile['avg_daily_plays'] < 5:
|
||||
days_lookback = 60 # Casual listener — show more
|
||||
elif profile['avg_daily_plays'] > 20:
|
||||
days_lookback = 21 # Heavy listener — keep it fresh
|
||||
logger.info(f"Recent albums window: {days_lookback} days (avg {profile['avg_daily_plays']:.1f} plays/day)")
|
||||
except Exception:
|
||||
pass
|
||||
cutoff_date = datetime.now() - timedelta(days=days_lookback)
|
||||
cached_count = {'spotify': 0, 'itunes': 0, 'deezer': 0}
|
||||
albums_checked = 0
|
||||
|
||||
|
|
@ -2614,6 +2625,42 @@ class WatchlistScanner:
|
|||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def _get_listening_profile(self, profile_id: int = 1) -> dict:
|
||||
"""Build a listening profile from the user's play history for personalized discovery.
|
||||
|
||||
Returns a dict with top artists, genres, listening velocity, etc.
|
||||
Falls back to empty/default values if no listening data exists.
|
||||
"""
|
||||
try:
|
||||
stats = self.database.get_listening_stats('30d')
|
||||
if not stats or stats.get('total_plays', 0) == 0:
|
||||
return {'has_data': False, 'top_artist_names': set(), 'top_genres': set(),
|
||||
'genre_weights': {}, 'artist_play_counts': {}, 'avg_daily_plays': 0, 'listening_diversity': 0}
|
||||
|
||||
top_artists = self.database.get_top_artists('30d', 20)
|
||||
top_artist_names = {a['name'].lower() for a in top_artists}
|
||||
|
||||
# Build play count lookup for artist penalty scoring
|
||||
artist_play_counts = {a['name'].lower(): a['play_count'] for a in top_artists}
|
||||
|
||||
genre_breakdown = self.database.get_genre_breakdown('30d')
|
||||
top_genres = {g['genre'].lower() for g in genre_breakdown[:5]} if genre_breakdown else set()
|
||||
genre_weights = {g['genre'].lower(): g['percentage'] for g in genre_breakdown} if genre_breakdown else {}
|
||||
|
||||
return {
|
||||
'has_data': True,
|
||||
'top_artist_names': top_artist_names,
|
||||
'artist_play_counts': artist_play_counts,
|
||||
'top_genres': top_genres,
|
||||
'genre_weights': genre_weights,
|
||||
'avg_daily_plays': stats.get('total_plays', 0) / 30,
|
||||
'listening_diversity': stats.get('unique_artists', 0),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not build listening profile: {e}")
|
||||
return {'has_data': False, 'top_artist_names': set(), 'top_genres': set(),
|
||||
'genre_weights': {}, 'avg_daily_plays': 0, 'listening_diversity': 0}
|
||||
|
||||
def curate_discovery_playlists(self, profile_id: int = 1):
|
||||
"""
|
||||
Curate consistent playlist selections that stay the same until next discovery pool update.
|
||||
|
|
@ -2621,6 +2668,8 @@ class WatchlistScanner:
|
|||
Supports both Spotify and iTunes sources - creates separate curated playlists for each.
|
||||
- Release Radar: Prioritizes freshness + popularity from recent releases
|
||||
- Discovery Weekly: Balanced mix of popular picks, deep cuts, and mid-tier tracks
|
||||
|
||||
Uses listening stats (if available) to personalize scoring.
|
||||
"""
|
||||
try:
|
||||
import random
|
||||
|
|
@ -2628,6 +2677,13 @@ class WatchlistScanner:
|
|||
|
||||
logger.info("Curating discovery playlists...")
|
||||
|
||||
# Build listening profile for personalization
|
||||
profile = self._get_listening_profile(profile_id)
|
||||
if profile['has_data']:
|
||||
logger.info(f"Listening profile: {len(profile['top_artist_names'])} top artists, "
|
||||
f"{len(profile['top_genres'])} top genres, "
|
||||
f"{profile['avg_daily_plays']:.1f} avg daily plays")
|
||||
|
||||
# Determine available sources
|
||||
spotify_available = self.spotify_client and self.spotify_client.is_spotify_authenticated()
|
||||
itunes_client, fallback_source = _get_fallback_metadata_client()
|
||||
|
|
@ -2637,6 +2693,28 @@ class WatchlistScanner:
|
|||
if spotify_available:
|
||||
sources_to_process.append('spotify')
|
||||
|
||||
# Pre-build artist genre cache from local DB for genre affinity scoring
|
||||
_artist_genre_cache = {}
|
||||
if profile['has_data']:
|
||||
try:
|
||||
import json as _json
|
||||
_conn = self.database._get_connection()
|
||||
_cur = _conn.cursor()
|
||||
_cur.execute("SELECT name, genres FROM artists WHERE genres IS NOT NULL AND genres != ''")
|
||||
for _row in _cur.fetchall():
|
||||
if not _row[0]:
|
||||
continue
|
||||
try:
|
||||
_parsed = _json.loads(_row[1])
|
||||
if isinstance(_parsed, list):
|
||||
_artist_genre_cache[_row[0].lower()] = {g.lower() for g in _parsed if g}
|
||||
except (ValueError, TypeError):
|
||||
_artist_genre_cache[_row[0].lower()] = {g.strip().lower() for g in _row[1].split(',') if g.strip()}
|
||||
_conn.close()
|
||||
logger.debug(f"Built genre cache for {len(_artist_genre_cache)} artists")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Curating playlists for sources: {sources_to_process}")
|
||||
|
||||
for source in sources_to_process:
|
||||
|
|
@ -2709,7 +2787,27 @@ class WatchlistScanner:
|
|||
popularity_score = max(40, 70 - days_old)
|
||||
is_single = album.get('album_type', 'album') == 'single'
|
||||
single_bonus = 20 if is_single else 0
|
||||
total_score = (recency_score * 0.5) + (popularity_score * 0.3) + single_bonus
|
||||
|
||||
# Personalization bonuses (from listening profile)
|
||||
genre_bonus = 0
|
||||
artist_bonus = 0
|
||||
overplay_penalty = 0
|
||||
if profile['has_data']:
|
||||
artist_lower = artist.lower()
|
||||
# Genre affinity: check album/API genres, then use cached DB genres
|
||||
artist_genres_lower = {g.lower() for g in (album.get('genres') or album_data.get('genres') or [])}
|
||||
if not artist_genres_lower:
|
||||
artist_genres_lower = _artist_genre_cache.get(artist_lower, set())
|
||||
if artist_genres_lower & profile['top_genres']:
|
||||
genre_bonus = 10
|
||||
# Artist familiarity: boost tracks from artists user listens to
|
||||
if artist_lower in profile['top_artist_names']:
|
||||
artist_bonus = 15
|
||||
# Overplay penalty: reduce score for artists user has heard too much
|
||||
if profile['artist_play_counts'].get(artist_lower, 0) > 20:
|
||||
overplay_penalty = -10
|
||||
|
||||
total_score = (recency_score * 0.45) + (popularity_score * 0.25) + single_bonus + genre_bonus + artist_bonus + overplay_penalty
|
||||
|
||||
full_track = {
|
||||
'id': track_id,
|
||||
|
|
@ -2807,10 +2905,36 @@ class WatchlistScanner:
|
|||
|
||||
logger.info(f"Discovery pool ({source}): {len(popular_picks)} popular, {len(balanced_mix)} mid-tier, {len(deep_cuts)} deep cuts")
|
||||
|
||||
# Balanced selection
|
||||
random.shuffle(popular_picks)
|
||||
random.shuffle(balanced_mix)
|
||||
random.shuffle(deep_cuts)
|
||||
# Serendipity-weighted selection within each tier
|
||||
def _serendipity_sort(tracks_list):
|
||||
"""Sort by serendipity: prefer unknown artists in genres user likes."""
|
||||
if not profile['has_data']:
|
||||
random.shuffle(tracks_list)
|
||||
return tracks_list
|
||||
|
||||
for t in tracks_list:
|
||||
score = 1.0
|
||||
t_artist = (t.artist_name or '').lower()
|
||||
t_genres = _artist_genre_cache.get(t_artist, set())
|
||||
|
||||
# Boost artists user has NEVER played (true discovery)
|
||||
if t_artist not in profile['top_artist_names']:
|
||||
score += 0.5
|
||||
# Boost genres user likes but hasn't explored
|
||||
if t_genres & profile['top_genres']:
|
||||
score += 0.3
|
||||
# Penalize artists user already plays heavily
|
||||
if profile['artist_play_counts'].get(t_artist, 0) > 10:
|
||||
score -= 0.4
|
||||
|
||||
t._serendipity = score + random.random() * 0.2 # Small random factor
|
||||
|
||||
tracks_list.sort(key=lambda t: getattr(t, '_serendipity', 1.0), reverse=True)
|
||||
return tracks_list
|
||||
|
||||
_serendipity_sort(popular_picks)
|
||||
_serendipity_sort(balanced_mix)
|
||||
_serendipity_sort(deep_cuts)
|
||||
|
||||
selected_tracks = []
|
||||
selected_tracks.extend(popular_picks[:20])
|
||||
|
|
@ -2831,6 +2955,81 @@ class WatchlistScanner:
|
|||
self.database.save_curated_playlist(playlist_key, discovery_weekly_tracks, profile_id=profile_id)
|
||||
logger.info(f"Discovery Weekly ({source}) curated: {len(discovery_weekly_tracks)} tracks")
|
||||
|
||||
# 3. "Because You Listen To" — personalized sections based on top played artists
|
||||
if profile['has_data']:
|
||||
logger.info("Building 'Because You Listen To' playlists...")
|
||||
top_played = self.database.get_top_artists('30d', 3)
|
||||
active_source_for_bylt = 'spotify' if spotify_available else fallback_source
|
||||
all_pool_tracks = self.database.get_discovery_pool_tracks(
|
||||
limit=2000, new_releases_only=False,
|
||||
source=active_source_for_bylt, profile_id=profile_id
|
||||
)
|
||||
|
||||
# Build source_artist_id → artist_name mapping from watchlist
|
||||
_wa_id_to_name = {}
|
||||
try:
|
||||
_wa_list = self.database.get_watchlist_artists(profile_id=profile_id)
|
||||
for _wa in _wa_list:
|
||||
_wa_id_to_name[str(_wa.id)] = (_wa.artist_name or '').lower()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
all_similar = self.database.get_top_similar_artists(limit=200, profile_id=profile_id)
|
||||
|
||||
for i, played_artist in enumerate(top_played):
|
||||
try:
|
||||
artist_name = played_artist['name']
|
||||
artist_lower = artist_name.lower()
|
||||
|
||||
# Find similar artists to this played artist via the similar_artists table
|
||||
similar_names = set()
|
||||
for s in all_similar:
|
||||
# Check if this similar artist's source matches our played artist
|
||||
src_id = str(getattr(s, 'source_artist_id', ''))
|
||||
src_name = _wa_id_to_name.get(src_id, '')
|
||||
sim_name = getattr(s, 'similar_artist_name', '') or ''
|
||||
if src_name == artist_lower and sim_name:
|
||||
similar_names.add(sim_name.lower())
|
||||
|
||||
if not similar_names:
|
||||
# Fallback: find pool tracks from same genre
|
||||
played_genres = _artist_genre_cache.get(artist_lower, set())
|
||||
if played_genres:
|
||||
for t in all_pool_tracks:
|
||||
t_artist_lower = (t.artist_name or '').lower()
|
||||
if t_artist_lower != artist_lower and _artist_genre_cache.get(t_artist_lower, set()) & played_genres:
|
||||
similar_names.add(t_artist_lower)
|
||||
if len(similar_names) >= 20:
|
||||
break
|
||||
|
||||
if not similar_names:
|
||||
continue
|
||||
|
||||
# Pick tracks from those similar artists in the pool
|
||||
matching_tracks = []
|
||||
for t in all_pool_tracks:
|
||||
if (t.artist_name or '').lower() in similar_names:
|
||||
if active_source_for_bylt == 'spotify' and t.spotify_track_id:
|
||||
matching_tracks.append(t.spotify_track_id)
|
||||
elif active_source_for_bylt == 'itunes' and t.itunes_track_id:
|
||||
matching_tracks.append(t.itunes_track_id)
|
||||
elif active_source_for_bylt == 'deezer' and t.deezer_track_id:
|
||||
matching_tracks.append(t.deezer_track_id)
|
||||
|
||||
if len(matching_tracks) >= 15:
|
||||
break
|
||||
|
||||
if matching_tracks:
|
||||
import random as _rnd
|
||||
_rnd.shuffle(matching_tracks)
|
||||
playlist_key = f'because_you_listen_to_{i}'
|
||||
self.database.save_curated_playlist(playlist_key, matching_tracks[:10], profile_id=profile_id)
|
||||
# Store the source artist name in metadata
|
||||
self.database.set_metadata(f'bylt_artist_{i}', artist_name)
|
||||
logger.info(f"'Because You Listen To {artist_name}': {len(matching_tracks[:10])} tracks")
|
||||
except Exception as e:
|
||||
logger.debug(f"Error building BYLT for {played_artist.get('name', '?')}: {e}")
|
||||
|
||||
# Also save without suffix for backward compatibility (use active source)
|
||||
active_source = 'spotify' if spotify_available else fallback_source
|
||||
release_radar_key = f'release_radar_{active_source}'
|
||||
|
|
|
|||
|
|
@ -35027,6 +35027,73 @@ def get_discover_release_radar():
|
|||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/because-you-listen-to', methods=['GET'])
|
||||
def get_discover_because_you_listen_to():
|
||||
"""Get 'Because You Listen To' sections — personalized by top played artists."""
|
||||
try:
|
||||
database = get_database()
|
||||
active_source = _get_active_discovery_source()
|
||||
pid = get_current_profile_id()
|
||||
|
||||
# Fetch pool tracks once for all sections
|
||||
pool_tracks = database.get_discovery_pool_tracks(limit=5000, new_releases_only=False, source=active_source, profile_id=pid)
|
||||
tracks_by_id = {}
|
||||
for t in pool_tracks:
|
||||
if active_source == 'spotify' and t.spotify_track_id:
|
||||
tracks_by_id[t.spotify_track_id] = t
|
||||
elif active_source == 'itunes' and t.itunes_track_id:
|
||||
tracks_by_id[t.itunes_track_id] = t
|
||||
elif active_source == 'deezer' and getattr(t, 'deezer_track_id', None):
|
||||
tracks_by_id[t.deezer_track_id] = t
|
||||
|
||||
sections = []
|
||||
for i in range(3):
|
||||
artist_name = database.get_metadata(f'bylt_artist_{i}')
|
||||
if not artist_name:
|
||||
continue
|
||||
track_ids = database.get_curated_playlist(f'because_you_listen_to_{i}', profile_id=pid)
|
||||
if not track_ids:
|
||||
continue
|
||||
|
||||
tracks = []
|
||||
for tid in track_ids:
|
||||
t = tracks_by_id.get(tid)
|
||||
if t:
|
||||
tracks.append({
|
||||
'id': tid,
|
||||
'name': t.track_name,
|
||||
'artist': t.artist_name,
|
||||
'album': t.album_name,
|
||||
'image_url': t.album_cover_url,
|
||||
'duration_ms': t.duration_ms,
|
||||
'popularity': t.popularity,
|
||||
})
|
||||
|
||||
if tracks:
|
||||
# Get artist image
|
||||
artist_image = None
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT thumb_url FROM artists WHERE LOWER(name) = LOWER(?) LIMIT 1", (artist_name,))
|
||||
row = cursor.fetchone()
|
||||
if row and row[0]:
|
||||
artist_image = fix_artist_image_url(row[0])
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sections.append({
|
||||
'artist_name': artist_name,
|
||||
'artist_image': artist_image,
|
||||
'tracks': tracks,
|
||||
})
|
||||
|
||||
return jsonify({'success': True, 'sections': sections})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting BYLT: {e}")
|
||||
return jsonify({'success': True, 'sections': []})
|
||||
|
||||
@app.route('/api/discover/weekly', methods=['GET'])
|
||||
def get_discover_weekly():
|
||||
"""Get discovery weekly playlist - curated selection that stays consistent until next update"""
|
||||
|
|
|
|||
|
|
@ -46770,6 +46770,7 @@ async function loadDiscoverPage() {
|
|||
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
|
||||
loadDiscoveryShuffle(), // NEW: Discovery Shuffle
|
||||
loadFamiliarFavorites(), // NEW: Familiar Favorites
|
||||
loadBecauseYouListenTo(), // Personalized by listening stats
|
||||
initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed)
|
||||
loadDecadeBrowserTabs(), // Time Machine (tabbed by decade)
|
||||
loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre)
|
||||
|
|
@ -50509,6 +50510,66 @@ async function loadFamiliarFavorites() {
|
|||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// BECAUSE YOU LISTEN TO
|
||||
// ===============================
|
||||
|
||||
async function loadBecauseYouListenTo() {
|
||||
try {
|
||||
const resp = await fetch('/api/discover/because-you-listen-to');
|
||||
if (!resp.ok) return;
|
||||
const data = await resp.json();
|
||||
if (!data.success || !data.sections || data.sections.length === 0) return;
|
||||
|
||||
// Find or create the BYLT container
|
||||
let byltContainer = document.getElementById('discover-bylt-sections');
|
||||
if (!byltContainer) {
|
||||
// Insert after the release radar section
|
||||
const releaseRadar = document.getElementById('discover-release-radar');
|
||||
if (!releaseRadar) return;
|
||||
const parent = releaseRadar.closest('.discover-section');
|
||||
if (!parent) return;
|
||||
|
||||
byltContainer = document.createElement('div');
|
||||
byltContainer.id = 'discover-bylt-sections';
|
||||
parent.parentNode.insertBefore(byltContainer, parent.nextSibling);
|
||||
}
|
||||
|
||||
byltContainer.innerHTML = data.sections.map((section, idx) => `
|
||||
<div class="discover-section bylt-section">
|
||||
<div class="discover-section-header">
|
||||
<div class="bylt-header">
|
||||
${section.artist_image ? `<img class="bylt-artist-img" src="${section.artist_image}" alt="" onerror="this.style.display='none'">` : ''}
|
||||
<div>
|
||||
<div class="discover-section-subtitle">Because you listen to</div>
|
||||
<h3 class="discover-section-title">${_esc(section.artist_name)}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-carousel" id="bylt-carousel-${idx}"></div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Render track cards in each carousel
|
||||
data.sections.forEach((section, idx) => {
|
||||
const carousel = document.getElementById(`bylt-carousel-${idx}`);
|
||||
if (!carousel) return;
|
||||
carousel.innerHTML = section.tracks.map(t => `
|
||||
<div class="discover-card">
|
||||
<div class="discover-card-image">
|
||||
${t.image_url ? `<img src="${t.image_url}" alt="" loading="lazy" onerror="this.src='/static/placeholder.png'">` : '<div class="discover-card-placeholder">🎵</div>'}
|
||||
</div>
|
||||
<div class="discover-card-title">${_esc(t.name)}</div>
|
||||
<div class="discover-card-artist">${_esc(t.artist)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.debug('Error loading Because You Listen To:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// BUILD A PLAYLIST FEATURE
|
||||
// ===============================
|
||||
|
|
|
|||
|
|
@ -25804,6 +25804,40 @@ body {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
/* Because You Listen To */
|
||||
.bylt-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bylt-artist-img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(var(--accent-rgb), 0.25);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.bylt-section {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.bylt-section .discover-section-subtitle {
|
||||
font-size: 12px;
|
||||
color: rgba(var(--accent-rgb), 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.bylt-section .discover-section-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.discover-section-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue