From 959562f6b0f0d2023d444c6a367f9c3124421a3e Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 8 May 2026 07:31:51 -0700 Subject: [PATCH] Delete Recently Added / Top Tracks / Forgotten Favorites / Familiar Favorites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner decision: not worth shipping. The four library-driven personalized sections were stubbed returning [] for ages because their schema prereqs didn't exist; the prior commit re-enabled them by routing through a new `_select_library_tracks` helper. Owner reviewed and chose to delete the sections entirely instead. Removed everywhere: - `core/personalized_playlists.py` — `get_recently_added`, `get_top_tracks`, `get_forgotten_favorites`, `get_familiar_favorites` + the `_select_library_tracks` helper (no other callers; verified via grep). - `web_server.py` — 4 route handlers (`/api/discover/personalized/recently-added`, `top-tracks`, `forgotten-favorites`, `familiar-favorites`). - `webui/index.html` — 4 `
` blocks (`#personalized-recently-added`, `#personalized-top-tracks`, `#personalized-forgotten-favorites`, `#personalized-familiar-favorites`). - `webui/static/discover.js` — 4 load functions (`loadPersonalizedRecentlyAdded`, `loadPersonalizedTopTracks`, `loadPersonalizedForgottenFavorites`, `loadFamiliarFavorites`), plus their entries in `loadDiscoverPage`'s Promise.all, plus 4 module-level state vars + 6 dead branches across `openDownloadModalForDiscoverPlaylist` / `startDiscoverPlaylistSync` and the sync-progress / rehydrate dispatchers. - `webui/static/helper.js` — 4 tooltip / docs entries. - `webui/static/sync-spotify.js` — 1 stale rehydrate dispatcher branch (`discover_familiar_favorites`) caught during the global grep pass. - `tests/test_personalized_playlists_id_gate.py` — 3 library-method tests + the test infrastructure that supported them (`tracks` schema, `insert_library_track` helper). Documentation header updated to reflect the deletion. Net: -527 / +2 lines across 7 files. What stays: - Daily Mixes (also in personalized package, intentionally paused — separate decision). - Popular Picks + Hidden Gems + Discovery Shuffle (alive, not affected by this deletion). - All 14 tests in the personalized-playlists test file still pass. - The PersonalizedPlaylistsService lift from the prior commit (`_select_discovery_tracks` etc) — those are still in active use by the surviving discovery_pool methods. DISCOVER_TRACK_SELECTION_REVIEW.md at repo root contains historical references to the four deleted endpoints. Treated as historical context (same policy as WHATS_NEW), left alone. 2219/2219 full suite green (was 2222 - 3 deleted tests = 2219). JS parses clean, ruff clean. --- core/personalized_playlists.py | 142 ------------------- tests/test_personalized_playlists_id_gate.py | 94 ------------ web_server.py | 81 ----------- webui/index.html | 77 ---------- webui/static/discover.js | 115 +-------------- webui/static/helper.js | 18 +-- webui/static/sync-spotify.js | 2 - 7 files changed, 2 insertions(+), 527 deletions(-) diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 9227178d..da507121 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -341,138 +341,6 @@ class PersonalizedPlaylistsService: return 'Other' - # ======================================== - # LIBRARY-BASED PLAYLISTS - # ======================================== - - def _select_library_tracks( - self, - *, - where_clause: str = "", - params: tuple = (), - order_by: str = "t.created_at DESC", - limit: int, - ) -> List[Dict]: - """ - Shared selector for library-based playlist methods. - - Builds and runs a SELECT against `tracks` joined to `albums` and - `artists`, with a baked-in ID-validity gate so callers cannot - accidentally return rows with no usable source IDs (which would fail - downstream when the user clicks download or tries to match the track - against an external metadata source). - - The WHERE clause always includes: - (t.spotify_track_id IS NOT NULL - OR t.itunes_track_id IS NOT NULL - OR t.deezer_id IS NOT NULL - OR t.musicbrainz_recording_id IS NOT NULL - OR t.audiodb_id IS NOT NULL) - - The ID gate is mandatory and not opt-out by design — mirrors the - shared `_select_discovery_tracks` helper. - - Args: - where_clause: optional SQL fragment appended to the WHERE clause. - Must start with "AND " if non-empty. - params: positional bindings for `?` placeholders in `where_clause`. - order_by: ORDER BY expression, used as-is. - limit: LIMIT applied to the query. - - Returns: - List of track dicts in the standard `_build_track_dict` shape with - `source='library'`. Returns `[]` on any error (logged at error - level). - """ - try: - query = f""" - SELECT - t.spotify_track_id AS spotify_track_id, - t.itunes_track_id AS itunes_track_id, - t.deezer_id AS deezer_track_id, - t.title AS track_name, - ar.name AS artist_name, - al.title AS album_name, - al.thumb_url AS album_cover_url, - t.duration AS duration_ms - FROM tracks t - LEFT JOIN albums al ON t.album_id = al.id - LEFT JOIN artists ar ON t.artist_id = ar.id - WHERE (t.spotify_track_id IS NOT NULL - OR t.itunes_track_id IS NOT NULL - OR t.deezer_id IS NOT NULL - OR t.musicbrainz_recording_id IS NOT NULL - OR t.audiodb_id IS NOT NULL) - {where_clause} - ORDER BY {order_by} - LIMIT ? - """ - - bound_params = tuple(params) + (limit,) - - with self.database._get_connection() as conn: - cursor = conn.cursor() - cursor.execute(query, bound_params) - rows = cursor.fetchall() - - results: List[Dict] = [] - for row in rows: - row_dict = dict(row) if hasattr(row, 'keys') else row - # Library tracks have no popularity score and no JSON blob; - # fill the standard shape with the documented defaults. - row_dict.setdefault('popularity', 0) - row_dict.setdefault('track_data_json', '{}') - # Guard against NULL artist/album joins so downstream string - # ops (diversity filter, dedupe) don't blow up. - if row_dict.get('artist_name') is None: - row_dict['artist_name'] = 'Unknown' - if row_dict.get('album_name') is None: - row_dict['album_name'] = 'Unknown' - if row_dict.get('track_name') is None: - row_dict['track_name'] = 'Unknown' - if row_dict.get('duration_ms') is None: - row_dict['duration_ms'] = 0 - results.append(self._build_track_dict(row_dict, 'library')) - - return results - - except Exception as e: - logger.error(f"Error in _select_library_tracks: {e}") - return [] - - def get_recently_added(self, limit: int = 50) -> List[Dict]: - """Get recently added tracks from the local library, newest first.""" - tracks = self._select_library_tracks( - order_by="t.created_at DESC", - limit=limit, - ) - logger.info(f"Recently Added: selected {len(tracks)} library tracks") - return tracks - - def get_top_tracks(self, limit: int = 50) -> List[Dict]: - """Get the user's all-time top library tracks by play count.""" - tracks = self._select_library_tracks( - where_clause="AND t.play_count > 0", - order_by="t.play_count DESC", - limit=limit, - ) - logger.info(f"Top Tracks: selected {len(tracks)} library tracks") - return tracks - - def get_forgotten_favorites(self, limit: int = 50) -> List[Dict]: - """Get tracks the user loved (>5 plays) but hasn't played in 90+ days.""" - tracks = self._select_library_tracks( - where_clause=( - "AND t.play_count > 5 " - "AND t.last_played IS NOT NULL " - "AND t.last_played < datetime('now', '-90 days')" - ), - order_by="t.play_count DESC", - limit=limit, - ) - logger.info(f"Forgotten Favorites: selected {len(tracks)} library tracks") - return tracks - def get_decade_playlist(self, decade: int, limit: int = 100, source: str = None) -> List[Dict]: """ Get tracks from a specific decade from discovery pool with diversity filtering. @@ -722,16 +590,6 @@ class PersonalizedPlaylistsService: logger.info(f"Discovery Shuffle ({active_source}): selected {len(tracks)} tracks") return tracks - def get_familiar_favorites(self, limit: int = 50) -> List[Dict]: - """Get tracks with medium play counts (3-15 plays) - reliable go-tos.""" - tracks = self._select_library_tracks( - where_clause="AND t.play_count BETWEEN 3 AND 15", - order_by="t.play_count DESC", - limit=limit, - ) - logger.info(f"Familiar Favorites: selected {len(tracks)} library tracks") - return tracks - # ======================================== # DAILY MIX (HYBRID PLAYLISTS) # ======================================== diff --git a/tests/test_personalized_playlists_id_gate.py b/tests/test_personalized_playlists_id_gate.py index ccc315a9..3f205982 100644 --- a/tests/test_personalized_playlists_id_gate.py +++ b/tests/test_personalized_playlists_id_gate.py @@ -10,14 +10,11 @@ section can't accidentally bypass it. Coverage: - `_select_discovery_tracks` filters out rows where every source ID is NULL - `_select_discovery_tracks` honors source filter + blacklist filter -- `_select_library_tracks` enforces the same gate against the `tracks` table - `_apply_diversity_filter` caps per-album + per-artist counts - `_compute_adaptive_diversity_limits` returns the right tier for the unique-artist count + relaxed flag - The 5 discovery_pool methods (decade / genre / popular_picks / hidden_gems / discovery_shuffle) each filter NULL-id rows -- The 4 library methods (recently_added / top_tracks / - forgotten_favorites / familiar_favorites) each filter NULL-id rows """ from __future__ import annotations @@ -71,31 +68,6 @@ class _FakeDatabase: CREATE TABLE discovery_artist_blacklist ( artist_name TEXT PRIMARY KEY ); - CREATE TABLE artists ( - id INTEGER PRIMARY KEY, - name TEXT - ); - CREATE TABLE albums ( - id INTEGER PRIMARY KEY, - title TEXT, - artist_id INTEGER, - thumb_url TEXT - ); - CREATE TABLE tracks ( - id INTEGER PRIMARY KEY, - album_id INTEGER, - artist_id INTEGER, - title TEXT, - duration INTEGER, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - play_count INTEGER DEFAULT 0, - last_played TIMESTAMP, - spotify_track_id TEXT, - itunes_track_id TEXT, - deezer_id TEXT, - musicbrainz_recording_id TEXT, - audiodb_id TEXT - ); """) self._conn.commit() @@ -116,26 +88,6 @@ class _FakeDatabase: ) self._conn.commit() - def insert_library_track(self, *, artist_name='Library Artist', - album_name='Library Album', track_name='Library Track', - **track_kwargs): - # Insert artist + album + track in one shot for convenience. - cur = self._conn.cursor() - cur.execute("INSERT INTO artists (name) VALUES (?)", (artist_name,)) - artist_id = cur.lastrowid - cur.execute( - "INSERT INTO albums (title, artist_id, thumb_url) VALUES (?, ?, ?)", - (album_name, artist_id, 'http://x/cover.jpg'), - ) - album_id = cur.lastrowid - track_kwargs.setdefault('duration', 200000) - cur.execute( - f"""INSERT INTO tracks (album_id, artist_id, title, {', '.join(track_kwargs.keys())}) - VALUES (?, ?, ?, {', '.join(['?'] * len(track_kwargs))})""", - (album_id, artist_id, track_name, *track_kwargs.values()), - ) - self._conn.commit() - def blacklist(self, artist_name): self._conn.execute( "INSERT INTO discovery_artist_blacklist (artist_name) VALUES (?)", @@ -375,49 +327,3 @@ def test_get_decade_playlist_filters_null_id_rows(service): assert [t['track_name'] for t in out] == ['Yes'] -# --------------------------------------------------------------------------- -# Library methods (re-enabled) -# --------------------------------------------------------------------------- - - -def test_get_recently_added_filters_null_id_rows(service): - svc, db = service - db.insert_library_track( - track_name='Has Spotify', spotify_track_id='sp1', - ) - db.insert_library_track( - track_name='No IDs', # all source ID columns NULL - ) - db.insert_library_track( - track_name='Has MB', musicbrainz_recording_id='mb1', - ) - out = svc.get_recently_added(limit=10) - names = sorted(t['track_name'] for t in out) - assert names == ['Has MB', 'Has Spotify'] - - -def test_get_top_tracks_requires_play_count(service): - svc, db = service - db.insert_library_track( - track_name='Played', spotify_track_id='sp1', play_count=5, - ) - db.insert_library_track( - track_name='Unplayed', spotify_track_id='sp2', play_count=0, - ) - out = svc.get_top_tracks(limit=10) - assert [t['track_name'] for t in out] == ['Played'] - - -def test_get_familiar_favorites_uses_play_count_band(service): - svc, db = service - db.insert_library_track( - track_name='InBand', spotify_track_id='sp1', play_count=8, - ) - db.insert_library_track( - track_name='TooLow', spotify_track_id='sp2', play_count=2, - ) - db.insert_library_track( - track_name='TooHigh', spotify_track_id='sp3', play_count=20, - ) - out = svc.get_familiar_favorites(limit=10) - assert [t['track_name'] for t in out] == ['InBand'] diff --git a/web_server.py b/web_server.py index f9709dcc..dfde1998 100644 --- a/web_server.py +++ b/web_server.py @@ -27189,66 +27189,6 @@ def refresh_seasonal_content(): # PERSONALIZED PLAYLISTS ENDPOINTS # ======================================== -@app.route('/api/discover/personalized/recently-added', methods=['GET']) -def get_recently_added_playlist(): - """Get recently added tracks from library""" - try: - from core.personalized_playlists import get_personalized_playlists_service - - database = get_database() - service = get_personalized_playlists_service(database, spotify_client) - - tracks = service.get_recently_added(limit=50) - - return jsonify({ - "success": True, - "tracks": tracks - }) - - except Exception as e: - logger.error(f"Error getting recently added playlist: {e}") - return jsonify({"success": False, "error": str(e)}), 500 - -@app.route('/api/discover/personalized/top-tracks', methods=['GET']) -def get_top_tracks_playlist(): - """Get user's all-time top tracks""" - try: - from core.personalized_playlists import get_personalized_playlists_service - - database = get_database() - service = get_personalized_playlists_service(database, spotify_client) - - tracks = service.get_top_tracks(limit=50) - - return jsonify({ - "success": True, - "tracks": tracks - }) - - except Exception as e: - logger.error(f"Error getting top tracks playlist: {e}") - return jsonify({"success": False, "error": str(e)}), 500 - -@app.route('/api/discover/personalized/forgotten-favorites', methods=['GET']) -def get_forgotten_favorites_playlist(): - """Get forgotten favorites - tracks you loved but haven't played recently""" - try: - from core.personalized_playlists import get_personalized_playlists_service - - database = get_database() - service = get_personalized_playlists_service(database, spotify_client) - - tracks = service.get_forgotten_favorites(limit=50) - - return jsonify({ - "success": True, - "tracks": tracks - }) - - except Exception as e: - logger.error(f"Error getting forgotten favorites playlist: {e}") - return jsonify({"success": False, "error": str(e)}), 500 - @app.route('/api/discover/personalized/decade/', methods=['GET']) def get_decade_playlist(decade): """Get tracks from a specific decade""" @@ -27353,27 +27293,6 @@ def get_discovery_shuffle(): logger.error(f"Error getting discovery shuffle playlist: {e}") return jsonify({"success": False, "error": str(e)}), 500 -@app.route('/api/discover/personalized/familiar-favorites', methods=['GET']) -def get_familiar_favorites(): - """Get Familiar Favorites playlist - reliable go-to tracks""" - try: - from core.personalized_playlists import get_personalized_playlists_service - - database = get_database() - service = get_personalized_playlists_service(database, spotify_client) - - limit = int(request.args.get('limit', 50)) - tracks = service.get_familiar_favorites(limit=limit) - - return jsonify({ - "success": True, - "tracks": tracks - }) - - except Exception as e: - logger.error(f"Error getting familiar favorites playlist: {e}") - return jsonify({"success": False, "error": str(e)}), 500 - @app.route('/api/discover/artist-blacklist', methods=['GET']) def get_discovery_artist_blacklist(): """Get all blacklisted discovery artists.""" diff --git a/webui/index.html b/webui/index.html index 7b0d993a..f4159613 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2985,17 +2985,6 @@
- - - - - - - - - - - -
diff --git a/webui/static/discover.js b/webui/static/discover.js index b1bee6e9..f5ead10d 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -15,14 +15,10 @@ let discoverSeasonalTracks = []; let currentSeasonKey = null; // Personalized playlists storage -let personalizedRecentlyAdded = []; -let personalizedTopTracks = []; -let personalizedForgottenFavorites = []; let personalizedPopularPicks = []; let personalizedHiddenGems = []; let personalizedDailyMixes = []; let personalizedDiscoveryShuffle = []; -let personalizedFamiliarFavorites = []; let buildPlaylistSelectedArtists = []; async function loadDiscoverPage() { @@ -35,16 +31,12 @@ async function loadDiscoverPage() { loadYourAlbums(), loadDiscoverRecentReleases(), loadSeasonalContent(), // Seasonal discovery - loadPersonalizedRecentlyAdded(), // NEW: Recently added from library // loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists (HIDDEN) loadDiscoverReleaseRadar(), loadDiscoverWeekly(), loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool - loadPersonalizedTopTracks(), // NEW: Your top tracks - loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites loadDiscoveryShuffle(), // NEW: Discovery Shuffle - loadFamiliarFavorites(), // NEW: Familiar Favorites loadBecauseYouListenTo(), // Personalized by listening stats loadCacheUndiscoveredAlbums(), // From metadata cache loadCacheGenreNewReleases(), // From metadata cache @@ -3826,75 +3818,6 @@ async function syncSeasonalPlaylist() { // PERSONALIZED PLAYLISTS // =============================== -async function loadPersonalizedRecentlyAdded() { - try { - const container = document.getElementById('personalized-recently-added'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/recently-added'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedRecentlyAdded = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading recently added:', error); - } -} - -async function loadPersonalizedTopTracks() { - try { - const container = document.getElementById('personalized-top-tracks'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/top-tracks'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedTopTracks = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading top tracks:', error); - } -} - -async function loadPersonalizedForgottenFavorites() { - try { - const container = document.getElementById('personalized-forgotten-favorites'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/forgotten-favorites'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedForgottenFavorites = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading forgotten favorites:', error); - } -} - async function loadPersonalizedPopularPicks() { try { const container = document.getElementById('personalized-popular-picks'); @@ -6612,29 +6535,6 @@ async function loadDiscoveryShuffle() { } } -async function loadFamiliarFavorites() { - try { - const container = document.getElementById('personalized-familiar-favorites'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/familiar-favorites?limit=50'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedFamiliarFavorites = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading familiar favorites:', error); - } -} - // =============================== // BECAUSE YOU LISTEN TO // =============================== @@ -7389,14 +7289,6 @@ async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName) tracks = personalizedHiddenGems; } else if (playlistType === 'discovery_shuffle') { tracks = personalizedDiscoveryShuffle; - } else if (playlistType === 'familiar_favorites') { - tracks = personalizedFamiliarFavorites; - } else if (playlistType === 'recently_added') { - tracks = personalizedRecentlyAdded; - } else if (playlistType === 'top_tracks') { - tracks = personalizedTopTracks; - } else if (playlistType === 'forgotten_favorites') { - tracks = personalizedForgottenFavorites; } else if (playlistType === 'build_playlist') { tracks = buildPlaylistTracks; } @@ -7516,8 +7408,6 @@ async function startDiscoverPlaylistSync(playlistType, playlistName) { tracks = personalizedHiddenGems; } else if (playlistType === 'discovery_shuffle') { tracks = personalizedDiscoveryShuffle; - } else if (playlistType === 'familiar_favorites') { - tracks = personalizedFamiliarFavorites; } else if (playlistType === 'build_playlist') { tracks = buildPlaylistTracks; } @@ -7648,7 +7538,7 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { 'release_radar': 'Fresh Tape', 'discovery_weekly': 'The Archives', 'seasonal_playlist': 'Seasonal Mix', 'popular_picks': 'Popular Picks', 'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle', - 'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist' + 'build_playlist': 'Custom Playlist' }; showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success'); setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); @@ -7714,7 +7604,6 @@ function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { 'popular_picks': 'Popular Picks', 'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle', - 'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist' }; const displayName = playlistNames[playlistType] || playlistType; @@ -8239,8 +8128,6 @@ async function rehydrateDiscoverDownloadModal(playlistId) { apiEndpoint = '/api/discover/hidden-gems'; } else if (playlistId === 'discover_discovery_shuffle') { apiEndpoint = '/api/discover/discovery-shuffle'; - } else if (playlistId === 'discover_familiar_favorites') { - apiEndpoint = '/api/discover/familiar-favorites'; } else if (playlistId === 'build_playlist_custom') { apiEndpoint = '/api/discover/build-playlist'; } else if (playlistId.startsWith('discover_lb_')) { diff --git a/webui/static/helper.js b/webui/static/helper.js index a8d204f0..e0cdcf20 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -1056,10 +1056,6 @@ const HELPER_CONTENT = { }, // Personalized Playlists - '#personalized-recently-added': { - title: 'Recently Added', - description: 'The latest tracks added to your library. A quick way to see what\'s new in your collection.', - }, '#personalized-popular-picks': { title: 'Popular Picks', description: 'Trending tracks from your discovery pool artists. These are the most popular songs from artists similar to the ones you follow.', @@ -1071,23 +1067,11 @@ const HELPER_CONTENT = { description: 'Rare and deeper cuts from your discovery pool artists. Lower popularity tracks that you might not find on mainstream playlists.', docsId: 'disc-playlists' }, - '#personalized-top-tracks': { - title: 'Your Top 50', - description: 'Your all-time most played tracks from listening history. A snapshot of your personal favorites.', - }, - '#personalized-forgotten-favorites': { - title: 'Forgotten Favorites', - description: 'Tracks you used to play frequently but haven\'t listened to in a while. Rediscover music you loved.', - }, '#personalized-discovery-shuffle': { title: 'Discovery Shuffle', description: 'Random tracks from your entire discovery pool — different every time you load. A surprise mix for when you want something new.', docsId: 'disc-playlists' }, - '#personalized-familiar-favorites': { - title: 'Familiar Favorites', - description: 'Your reliable go-to tracks. Consistently played songs that define your taste.', - }, // Curated Playlists '#release-radar-playlist': { @@ -3432,7 +3416,7 @@ const WHATS_NEW = { '2.4.3': [ // --- post-2.4.2 dev work — entries hidden by _getLatestWhatsNewVersion until the build version bumps --- { date: 'Unreleased — 2.4.3 dev cycle' }, - { title: 'Discover: Stop Showing Undownloadable Tracks (+Lift)', desc: 'audit found multiple discover-page sections (hidden gems / discovery shuffle / popular picks / decade browser / genre browser) had no `WHERE (spotify_track_id IS NOT NULL OR itunes_track_id IS NOT NULL OR ...)` gate on their selection sql. tracks with no source ids in the discovery pool were getting displayed, the user would click download, and the download would silently fail because there was nothing to look up. fix: lifted all five discovery_pool selection methods + four library-driven methods (recently added / top tracks / forgotten favorites / familiar favorites — these were stubbed returning [] because the schema predated the source-id columns; now re-enabled properly) into shared private helpers (`_select_discovery_tracks`, `_select_library_tracks`, `_apply_diversity_filter`, `_compute_adaptive_diversity_limits`) on `PersonalizedPlaylistsService`. mandatory id-validity gate is hard-coded into both selectors — no opt-out flag, every public method inherits it for free. behavior preserved: same diversity tiers, same over-fetch multipliers, same popularity thresholds, same blacklist filter. ~314 lines of repeated select/diversity boilerplate collapsed across the 5 discovery methods (-55% on those methods\' business logic). on the frontend, lifted the duplicated decade-browser + genre-browser tab management (~314 lines of identical fetch-tabs / render-tabstrip / fetch-content / render-tracklist / wire-sync-button code) into one shared `createTabbedBrowserSection(config)` helper. each browser is now a thin wrapper: ~3 lines per public function. 17 new tests pin the gate (every selector filters null-id rows), the diversity caps, the adaptive limit tiers, the source filter, and the blacklist filter. 2222/2222 full suite green.', page: 'discover' }, + { title: 'Discover: Stop Showing Undownloadable Tracks (+Lift +Cleanup)', desc: 'audit found multiple discover-page sections (hidden gems / discovery shuffle / popular picks / decade browser / genre browser) had no `WHERE (spotify_track_id IS NOT NULL OR itunes_track_id IS NOT NULL OR ...)` gate on their selection sql. tracks with no source ids in the discovery pool were getting displayed, the user would click download, and the download would silently fail because there was nothing to look up. fix: lifted all five discovery_pool selection methods into shared private helpers (`_select_discovery_tracks`, `_apply_diversity_filter`, `_compute_adaptive_diversity_limits`) on `PersonalizedPlaylistsService`. mandatory id-validity gate is hard-coded into the selector — no opt-out flag, every public method inherits it for free. behavior preserved: same diversity tiers, same over-fetch multipliers, same popularity thresholds, same blacklist filter. ~314 lines of repeated select/diversity boilerplate collapsed across the 5 methods (-55% on those methods\' business logic). also deleted four sections that had been stubbed returning [] for ages (recently added / top tracks / forgotten favorites / familiar favorites) — frontend, backend endpoints, html blocks, helper docs, all gone. on the frontend, lifted the duplicated decade-browser + genre-browser tab management (~314 lines of identical fetch-tabs / render-tabstrip / fetch-content / render-tracklist / wire-sync-button code) into one shared `createTabbedBrowserSection(config)` helper. each browser is now a thin wrapper: ~3 lines per public function. 14 new tests pin the gate (every selector filters null-id rows), the diversity caps, the adaptive limit tiers, the source filter, and the blacklist filter.', page: 'discover' }, { title: 'Internal: Discover Controller — Cin Pre-Review Polish', desc: 'tightened the controller before opening the PR. (1) dropped the magic `extractItems` defaults — controller used to auto-pull `data.items` / `data.albums` / `data.artists` / `data.tracks` / `data.results` if no extractor was provided. removed the fallback chain. each section now MUST supply its own `extractItems(data) => array` callback. cin standard: explicit > implicit; the auto-fallback could silently grab the wrong key on endpoints that return multiple arrays. validated at register-time so misuse fails immediately. all 10 existing call sites already had explicit extractors so no migration churn. (2) replaced the `renderItems` returning null convention (used by Your Albums + manualDom-style sections) with an explicit `manualDom: true` config flag. clearer intent at the call site, less likely to be confused with a renderer error. (3) added a minimal node `--test` JS test file at `tests/static/test_discover_section_controller.mjs` — 32 tests pin the lifecycle contract: config validation (every required field), happy-path fetch+render, empty/stale/error states, no-fetch `data:` mode, manualDom mode, callable `fetchUrl`, load coalescing, refresh bypass, hook error containment, error toasts. runs via `node --test tests/static/` directly, OR via the regular pytest sweep (`tests/test_discover_section_controller_js.py` shells out to node and asserts a clean exit). skipped gracefully when node isn\'t available or is < 22. closes the "controller is a contract, pin it at the test boundary" gap that cin would have flagged. 2205/2205 full suite green (was 2204 + 1 new pytest wrapper); 32/32 node --test pass; ruff clean; js parses clean.', page: 'discover' }, { title: 'Internal: Discover Cleanup Round — Toast Errors, Stale State, Skipped Sections', desc: 'follow-up to the controller migration. extended `createDiscoverSectionController` with the hooks the per-section migrations surfaced as needed: callable `fetchUrl` (resolves the seasonal-playlist recreate-on-key-change hack), no-fetch `data:` mode (lets render-only sections like seasonal albums use the controller without inventing a fake endpoint), `beforeLoad` hook (lets dynamically-inserted sections like because-you-listen-to ensure their container exists before the spinner shows), `onSuccess(data)` hook (cleaner home for sibling header / subtitle / button updates than folding them into renderItems), and an `isStale` / `onStale` / `renderStale` triple for the third render state (data is empty BUT upstream is still discovering — show updating UI + start a poller, instead of the bare empty-state copy). turned on `showErrorToast: true` for every migrated section — section load failures now surface a global toast instead of silently spinning forever or swallowing into console.debug. that\'s the JohnBaumb #369 pattern applied at the UI layer. migrated the two sections that didn\'t fit the original controller contract: `loadYourAlbums` (uses isStale/onStale for stale-fetch UI + onSuccess for subtitle/filters/download-button side-effects + renderItems returning null since it delegates to the existing grid renderer) and `loadSeasonalAlbums` (uses no-fetch data mode since the parent `loadSeasonalContent` already fetched the season payload). also lifted the duplicated decade-tab + genre-tab sync-status block (✓/⏳/✗/percentage) into a `_renderSyncStatusBlock(idPrefix)` helper — two call sites now share one implementation. listenbrainz playlists keep their own block because the semantics differ (matching progress vs download progress). audit found the 13 supposedly-dead hidden sections aren\'t dead at all — they\'re gated on user data (discovery pool, library content, metadata cache) and self-surface when their data exists. removed one orphaned `loadPersonalizedDailyMixes()` call from `blockDiscoveryArtist` — daily mixes is intentionally paused, refreshing it from there was a no-op.', page: 'discover' }, { title: 'Internal: Migrate 7 More Discover Sections to the Controller', desc: 'follow-up to the foundation commit. migrated fresh tape, the archives, time machine intro carousel, browse by genre intro carousel, seasonal mix, your artists, and because-you-listen-to onto `createDiscoverSectionController`. each one drops its own hand-rolled try/catch + spinner injection + empty-state HTML + error swallow in favor of a config object — controller owns the lifecycle. net 76 lines smaller in discover.js even after adding the per-section render helpers. skipped two sections that don\'t fit the controller\'s single-fetch / single-render-target shape: `loadYourAlbums` (paginated grid + filters, four separate UI elements updated) and `loadSeasonalAlbums` (no fetch — receives pre-fetched data from parent). hidden / dead sections (~13 of them) untouched in this pass — separate audit commit will surface or kill them. controller extension candidates surfaced for follow-up: callable `fetchUrl` (so seasonal playlist doesn\'t need controller-recreate-on-key-change), explicit `isStale` / `onStale` hook (so your-artists doesn\'t fold stale handling into renderItems), `beforeLoad` hook (so because-you-listen-to can let the controller own the dynamic container creation), and a no-fetch `data:` mode (so render-only sections like seasonal albums can use the controller). zero behavior changes — every public load function keeps its name + signature so existing callers, refresh buttons, and dashboard wiring don\'t notice the swap.', page: 'discover' }, diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index 95e592c2..796d1ce5 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -293,8 +293,6 @@ async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, b apiEndpoint = '/api/discover/hidden-gems'; } else if (virtualPlaylistId === 'discover_discovery_shuffle') { apiEndpoint = '/api/discover/discovery-shuffle'; - } else if (virtualPlaylistId === 'discover_familiar_favorites') { - apiEndpoint = '/api/discover/familiar-favorites'; } else if (virtualPlaylistId === 'build_playlist_custom') { apiEndpoint = '/api/discover/build-playlist'; } else if (virtualPlaylistId.startsWith('discover_lb_')) {