Delete Recently Added / Top Tracks / Forgotten Favorites / Familiar Favorites

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 `<div class="discover-section">` 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.
This commit is contained in:
Broque Thomas 2026-05-08 07:31:51 -07:00
parent 44dd7f980f
commit 959562f6b0
7 changed files with 2 additions and 527 deletions

View file

@ -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)
# ========================================

View file

@ -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']

View file

@ -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/<int: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."""

View file

@ -2985,17 +2985,6 @@
</div>
</div>
<!-- Recently Added Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<h2 class="discover-section-title">🆕 Recently Added</h2>
<p class="discover-section-subtitle">Latest additions to your library</p>
</div>
<div class="discover-playlist-container compact" id="personalized-recently-added">
<!-- Content will be populated dynamically -->
</div>
</div>
<!-- Daily Mixes Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
@ -3188,28 +3177,6 @@
</div>
</div>
<!-- Your Top 50 Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<h2 class="discover-section-title">🏆 Your Top 50</h2>
<p class="discover-section-subtitle">All-time favorites from your library</p>
</div>
<div class="discover-playlist-container compact" id="personalized-top-tracks">
<!-- Content will be populated dynamically -->
</div>
</div>
<!-- Forgotten Favorites Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<h2 class="discover-section-title">💎 Forgotten Favorites</h2>
<p class="discover-section-subtitle">Rediscover tracks you used to love</p>
</div>
<div class="discover-playlist-container compact" id="personalized-forgotten-favorites">
<!-- Content will be populated dynamically -->
</div>
</div>
<!-- Discovery Shuffle Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
@ -3255,50 +3222,6 @@
</div>
</div>
<!-- Familiar Favorites Section -->
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">❤️ Familiar Favorites</h2>
<p class="discover-section-subtitle">Your reliable go-to tracks</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary"
onclick="openDownloadModalForDiscoverPlaylist('familiar_favorites', 'Familiar Favorites')"
title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary" id="familiar-favorites-sync-btn"
onclick="startDiscoverPlaylistSync('familiar_favorites', 'Familiar Favorites')"
title="Sync to media server">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
</div>
</div>
<!-- Sync Status Display -->
<div class="discover-sync-status" id="familiar-favorites-sync-status" style="display: none;">
<div class="sync-status-content">
<div class="sync-status-label">
<span class="sync-icon"></span>
<span>Syncing to media server...</span>
</div>
<div class="sync-status-stats">
<span class="sync-stat"><span
id="familiar-favorites-sync-completed">0</span></span>
<span class="sync-stat"><span id="familiar-favorites-sync-pending">0</span></span>
<span class="sync-stat"><span id="familiar-favorites-sync-failed">0</span></span>
<span class="sync-stat">(<span
id="familiar-favorites-sync-percentage">0</span>%)</span>
</div>
</div>
</div>
<div class="discover-playlist-container compact" id="personalized-familiar-favorites">
<!-- Content will be populated dynamically -->
</div>
</div>
<!-- Build a Playlist Section -->
<div class="discover-section">
<div class="discover-section-header">

View file

@ -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_')) {

View file

@ -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 &lt; 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' },

View file

@ -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_')) {