From 232481fd13b24cfc202ab27f28b239dc2046781b Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:54:37 -0700 Subject: [PATCH] Personalize discovery playlists using listening stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- core/watchlist_scanner.py | 213 ++++++++++++++++++++++++++++++++++++-- web_server.py | 67 ++++++++++++ webui/static/script.js | 61 +++++++++++ webui/static/style.css | 34 ++++++ 4 files changed, 368 insertions(+), 7 deletions(-) diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 0a9f5cc7..e282aaea 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -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}' diff --git a/web_server.py b/web_server.py index 80e27070..1eb5cbe7 100644 --- a/web_server.py +++ b/web_server.py @@ -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""" diff --git a/webui/static/script.js b/webui/static/script.js index a6e5b3da..83c8f7c2 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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) => ` +
+
+
+ ${section.artist_image ? `` : ''} +
+
Because you listen to
+

${_esc(section.artist_name)}

+
+
+
+ +
+ `).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 => ` +
+
+ ${t.image_url ? `` : '
🎵
'} +
+
${_esc(t.name)}
+
${_esc(t.artist)}
+
+ `).join(''); + }); + + } catch (error) { + console.debug('Error loading Because You Listen To:', error); + } +} + // =============================== // BUILD A PLAYLIST FEATURE // =============================== diff --git a/webui/static/style.css b/webui/static/style.css index 671c60a2..4e1bd5df 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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;