User-facing bug found in the discover-page audit: multiple 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 ...)` gate. Tracks with no source IDs
in the discovery pool got displayed, the user clicked download, the
download silently failed because there was nothing to look up.
Lift + gate
`PersonalizedPlaylistsService` had 5 selection methods that all shared
the same shape — connect to DB, run a SELECT against `discovery_pool`
with different WHERE clauses, optionally apply diversity, return
list of track dicts. ~366 lines of business logic, ~55% of which was
repeated boilerplate.
Three new private helpers consolidate everything:
- `_select_discovery_tracks(*, source, extra_where, extra_params,
order_by, fetch_limit, extra_columns)` — shared SELECT against
`discovery_pool`. The mandatory ID gate is hard-coded into the
WHERE clause: no opt-out flag, every method inherits it for free.
Plus the source filter and the blacklist filter — same shape every
selector needs.
- `_apply_diversity_filter(tracks, *, max_per_album, max_per_artist,
limit)` — per-album / per-artist cap loop, returns trimmed list.
Lifted from the inline duplicates in decade / genre / popular_picks.
- `_compute_adaptive_diversity_limits(tracks, *, relaxed=False)` —
step-function tiers based on unique-artist count. `relaxed=True`
gives the slightly looser limits the genre playlist used vs the
decade playlist.
Re-enable 4 library methods
`get_recently_added`, `get_top_tracks`, `get_forgotten_favorites`,
`get_familiar_favorites` were all stubs (`return []`) because they
predated the schema columns they need. Schema now has them:
`tracks.created_at`, `tracks.play_count`, `tracks.last_played`, and
the source ID columns added in earlier work.
New `_select_library_tracks(*, where_clause, params, order_by, limit)`
helper mirrors the discovery selector but targets the `tracks` table
joined against `albums` + `artists`. Mandatory ID gate lives in the
helper too: every library method automatically rejects rows where
spotify_track_id, itunes_track_id, deezer_id,
musicbrainz_recording_id, AND audiodb_id are all NULL.
Selection rules:
- `get_recently_added` — ORDER BY created_at DESC
- `get_top_tracks` — WHERE play_count > 0 ORDER BY play_count DESC
- `get_forgotten_favorites` — WHERE play_count > 5 AND last_played
< (now - 90 days) ORDER BY play_count DESC
- `get_familiar_favorites` — WHERE play_count BETWEEN 3 AND 15
Tests
`tests/test_personalized_playlists_id_gate.py` — 17 tests pinning:
- `_select_discovery_tracks` filters NULL-id rows, honors source +
blacklist + extra_where
- `_apply_diversity_filter` caps per-album + per-artist + stops at
limit
- `_compute_adaptive_diversity_limits` returns the right tier for
unique-artist count + relaxed flag
- All 5 discovery methods (decade, popular_picks, hidden_gems,
discovery_shuffle, genre is exercised via the helper) reject
NULL-id rows
- All 4 library methods reject NULL-id rows + honor their
play-count rules
Behavior preserved
Same diversity tiers, same over-fetch multipliers (10x for decade /
genre, 3x for popular_picks), same `popularity DESC, RANDOM()`
ordering, same `popularity >= 60` / `< 40` thresholds, same
blacklist filter. Public method signatures unchanged — `web_server.py`
needs zero edits.
Net file: 1089 → ~1170 LOC (helpers + docstrings), but actual
business logic across the 9 methods went from ~418 lines down to
~195 (-53%).
2222/2222 full suite green (was 2205 + 17 new). Ruff clean.