PersonalizedPlaylistsService: bake in ID-validity gate, lift selectors

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.
This commit is contained in:
Broque Thomas 2026-05-08 07:14:36 -07:00
parent 1f2b8f8ccd
commit 0701bcc213
2 changed files with 884 additions and 379 deletions

View file

@ -105,8 +105,191 @@ class PersonalizedPlaylistsService:
from core.metadata_service import get_primary_source
return get_primary_source()
# Standard column set returned by every discovery_pool selector.
# Callers can request additional columns via the `extra_columns` parameter
# of `_select_discovery_tracks` (e.g. `release_date`, `artist_genres`).
_STANDARD_DISCOVERY_COLUMNS: Tuple[str, ...] = (
'spotify_track_id',
'itunes_track_id',
'track_name',
'artist_name',
'album_name',
'album_cover_url',
'duration_ms',
'popularity',
'track_data_json',
'source',
)
def _select_discovery_tracks(
self,
*,
source: str,
extra_where: str = "",
extra_params: tuple = (),
order_by: str = "RANDOM()",
fetch_limit: int,
extra_columns: tuple = (),
) -> List[Dict]:
"""
Shared selector for discovery_pool playlist methods.
Builds and runs a SELECT against `discovery_pool` 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).
The WHERE clause always includes:
source = ?
AND (spotify_track_id IS NOT NULL OR itunes_track_id IS NOT NULL)
AND LOWER(artist_name) NOT IN
(SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
The ID gate is mandatory and not opt-out by design if a future
method needs to skip it, that's a design discussion, not a flag.
Callers compose additional filters via `extra_where` (a SQL fragment
beginning with "AND ...") and `extra_params` (positional bindings for
any `?` placeholders inside `extra_where`).
Diversity filtering is the caller's responsibility — apply
`_apply_diversity_filter` to the returned list if needed.
Args:
source: discovery_pool.source value to filter on.
extra_where: optional SQL fragment appended to the WHERE clause.
Must start with "AND " if non-empty.
extra_params: positional bindings for `?` placeholders in
`extra_where`.
order_by: ORDER BY expression, used as-is (e.g. "RANDOM()",
"popularity DESC, RANDOM()").
fetch_limit: LIMIT applied to the query. Callers that intend to
run a diversity filter should over-fetch (e.g. `limit * 3`).
extra_columns: additional columns to SELECT beyond
`_STANDARD_DISCOVERY_COLUMNS` (e.g. `('release_date',)`).
Returns:
List of track dicts via `_build_track_dict`. Returns `[]` on any
error (logged at error level).
"""
try:
columns = self._STANDARD_DISCOVERY_COLUMNS + tuple(extra_columns)
select_cols = ",\n ".join(columns)
query = f"""
SELECT
{select_cols}
FROM discovery_pool
WHERE source = ?
AND (spotify_track_id IS NOT NULL OR itunes_track_id IS NOT NULL)
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
{extra_where}
ORDER BY {order_by}
LIMIT ?
"""
params = (source,) + tuple(extra_params) + (fetch_limit,)
with self.database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(query, params)
rows = cursor.fetchall()
return [self._build_track_dict(row, source) for row in rows]
except Exception as e:
logger.error(f"Error in _select_discovery_tracks (source={source}): {e}")
return []
def _apply_diversity_filter(
self,
tracks: List[Dict],
*,
max_per_album: int,
max_per_artist: int,
limit: int,
) -> List[Dict]:
"""
Apply per-album / per-artist diversity caps to a track list.
Iterates `tracks` in order, accepting each only if its album count
is still under `max_per_album` AND its artist count is still under
`max_per_artist`. Stops once `limit` tracks have been accepted.
Returns a new list, already trimmed to at most `limit` items.
"""
tracks_by_album: Dict[str, int] = {}
tracks_by_artist: Dict[str, int] = {}
diverse_tracks: List[Dict] = []
for track in tracks:
album = track['album_name']
artist = track['artist_name']
album_count = tracks_by_album.get(album, 0)
artist_count = tracks_by_artist.get(artist, 0)
if album_count < max_per_album and artist_count < max_per_artist:
diverse_tracks.append(track)
tracks_by_album[album] = album_count + 1
tracks_by_artist[artist] = artist_count + 1
if len(diverse_tracks) >= limit:
break
return diverse_tracks
def _compute_adaptive_diversity_limits(
self,
tracks: List[Dict],
*,
relaxed: bool = False,
) -> Tuple[int, int]:
"""
Pick (max_per_album, max_per_artist) caps based on artist variety.
Mirrors the step-functions previously inlined in the decade and
genre playlist methods. With more unique artists we can be strict;
with fewer we relax to still hit the requested track count.
When `relaxed=True` (used for genre playlists), the moderate and
low bands use looser caps and an extra "very limited" tier kicks
in below 5 unique artists.
Args:
tracks: candidate track list to inspect.
relaxed: True to apply the looser genre-playlist limits.
Returns:
Tuple of (max_per_album, max_per_artist).
"""
unique_artists = len(set(t['artist_name'] for t in tracks))
if relaxed:
# Genre playlist tiers
if unique_artists >= 20:
return 3, 5
if unique_artists >= 10:
return 4, 10
if unique_artists >= 5:
return 6, 15
return 8, 25
# Decade-style strict tiers
if unique_artists >= 20:
return 3, 5
if unique_artists >= 10:
return 4, 8
return 5, 12
def _build_track_dict(self, row, source: str) -> Dict:
"""Build a standardized track dictionary from a database row."""
"""Build a standardized track dictionary from a database row.
If the row carries the optional `artist_genres` column (selected via
`_select_discovery_tracks(extra_columns=('artist_genres',))`), the raw
JSON string is passed through under `_artist_genres_raw` so callers
can run Python-side genre matching without re-querying the row.
"""
# Convert sqlite3.Row to dict if needed (Row objects don't support .get())
if hasattr(row, 'keys'):
row = dict(row)
@ -118,7 +301,7 @@ class PersonalizedPlaylistsService:
except:
track_data = None
return {
result = {
'track_id': row.get('spotify_track_id') or row.get('itunes_track_id') or row.get('deezer_track_id'),
'spotify_track_id': row.get('spotify_track_id'),
'itunes_track_id': row.get('itunes_track_id'),
@ -130,9 +313,19 @@ class PersonalizedPlaylistsService:
'duration_ms': row.get('duration_ms', 0),
'popularity': row.get('popularity', 0),
'track_data_json': track_data,
'source': source
'source': source,
}
# Pass through optional extra columns under underscore-prefixed keys
# so callers that requested them via `extra_columns` can use them
# without having to re-query.
if 'artist_genres' in row:
result['_artist_genres_raw'] = row.get('artist_genres')
if 'release_date' in row:
result['_release_date'] = row.get('release_date')
return result
@staticmethod
def get_parent_genre(spotify_genre: str) -> str:
"""
@ -152,52 +345,133 @@ class PersonalizedPlaylistsService:
# LIBRARY-BASED PLAYLISTS
# ========================================
def get_recently_added(self, limit: int = 50) -> List[Dict]:
def _select_library_tracks(
self,
*,
where_clause: str = "",
params: tuple = (),
order_by: str = "t.created_at DESC",
limit: int,
) -> List[Dict]:
"""
Get recently added tracks from library.
Shared selector for library-based playlist methods.
Returns tracks ordered by date_added DESC
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).
NOTE: This requires library tracks to have Spotify metadata which may not be available.
Returns empty list if schema incompatible.
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:
logger.warning("Recently Added requires Spotify-linked library tracks - returning empty")
return []
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 getting recently added tracks: {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 user's all-time top tracks based on play count.
NOTE: This requires library tracks to have Spotify metadata which may not be available.
Returns empty list if schema incompatible.
"""
try:
logger.warning("Top Tracks requires Spotify-linked library tracks - returning empty")
return []
except Exception as e:
logger.error(f"Error getting top tracks: {e}")
return []
"""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 you loved but haven't played recently.
NOTE: This requires library tracks to have Spotify metadata which may not be available.
Returns empty list if schema incompatible.
"""
try:
logger.warning("Forgotten Favorites requires Spotify-linked library tracks - returning empty")
return []
except Exception as e:
logger.error(f"Error getting forgotten favorites: {e}")
return []
"""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]:
"""
@ -208,99 +482,46 @@ class PersonalizedPlaylistsService:
limit: Maximum tracks to return
source: Optional source filter ('spotify' or 'itunes'), auto-detects if not provided
"""
try:
start_year = decade
end_year = decade + 9
start_year = decade
end_year = decade + 9
active_source = source or self._get_active_source()
# Determine active source if not specified
active_source = source or self._get_active_source()
# Over-fetch 10x for diversity filtering headroom.
all_tracks = self._select_discovery_tracks(
source=active_source,
extra_where=(
"AND release_date IS NOT NULL "
"AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ?"
),
extra_params=(start_year, end_year),
order_by="RANDOM()",
fetch_limit=limit * 10,
extra_columns=('release_date',),
)
with self.database._get_connection() as conn:
cursor = conn.cursor()
# Query discovery_pool - get 10x more for diversity filtering, filtered by source
cursor.execute("""
SELECT
spotify_track_id,
itunes_track_id,
track_name,
artist_name,
album_name,
album_cover_url,
duration_ms,
popularity,
release_date,
track_data_json,
source
FROM discovery_pool
WHERE release_date IS NOT NULL
AND CAST(SUBSTR(release_date, 1, 4) AS INTEGER) BETWEEN ? AND ?
AND source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
ORDER BY RANDOM()
LIMIT ?
""", (start_year, end_year, active_source, limit * 10))
rows = cursor.fetchall()
all_tracks = []
for row in rows:
all_tracks.append(self._build_track_dict(row, active_source))
if not all_tracks:
logger.warning(f"No tracks found for {decade}s")
return []
# Shuffle first for randomness
import random
random.shuffle(all_tracks)
# Count unique artists to determine diversity level
unique_artists = len(set(track['artist_name'] for track in all_tracks))
# Adaptive diversity limits based on artist variety
if unique_artists >= 20:
# Good variety - apply diversity constraints
max_per_album = 3
max_per_artist = 5
elif unique_artists >= 10:
# Moderate variety - more lenient
max_per_album = 4
max_per_artist = 8
else:
# Low variety - very lenient to hit 50 tracks
max_per_album = 5
max_per_artist = 12
logger.info(f"{decade}s has {unique_artists} unique artists - using limits: {max_per_album} per album, {max_per_artist} per artist")
# Apply diversity constraints
tracks_by_album = {}
tracks_by_artist = {}
diverse_tracks = []
for track in all_tracks:
album = track['album_name']
artist = track['artist_name']
# Count current tracks for this album/artist
album_count = tracks_by_album.get(album, 0)
artist_count = tracks_by_artist.get(artist, 0)
if album_count < max_per_album and artist_count < max_per_artist:
diverse_tracks.append(track)
tracks_by_album[album] = album_count + 1
tracks_by_artist[artist] = artist_count + 1
if len(diverse_tracks) >= limit:
break
logger.info(f"Found {len(diverse_tracks)} tracks from {decade}s in discovery pool (adaptive diversity)")
return diverse_tracks[:limit]
except Exception as e:
logger.error(f"Error getting decade playlist for {decade}s: {e}")
if not all_tracks:
logger.warning(f"No tracks found for {decade}s")
return []
random.shuffle(all_tracks)
max_per_album, max_per_artist = self._compute_adaptive_diversity_limits(all_tracks)
unique_artists = len(set(t['artist_name'] for t in all_tracks))
logger.info(
f"{decade}s has {unique_artists} unique artists - using limits: "
f"{max_per_album} per album, {max_per_artist} per artist"
)
diverse_tracks = self._apply_diversity_filter(
all_tracks,
max_per_album=max_per_album,
max_per_artist=max_per_artist,
limit=limit,
)
logger.info(f"Found {len(diverse_tracks)} tracks from {decade}s in discovery pool (adaptive diversity)")
return diverse_tracks
def get_available_genres(self, source: str = None) -> List[Dict]:
"""
Get list of consolidated parent genres with track counts from discovery pool.
@ -363,238 +584,128 @@ class PersonalizedPlaylistsService:
logger.error(f"Error getting available genres: {e}")
return []
def _genre_matches(self, artist_genres_json: Optional[str], search_keywords: List[str]) -> bool:
"""Return True if any artist genre in the JSON-encoded column matches any keyword."""
if not artist_genres_json:
return False
try:
genres = json.loads(artist_genres_json)
except Exception:
return False
for artist_genre in genres:
artist_genre_lower = artist_genre.lower()
for keyword in search_keywords:
if keyword in artist_genre_lower:
return True
return False
def get_genre_playlist(self, genre: str, limit: int = 50, source: str = None) -> List[Dict]:
"""
Get tracks from a specific genre with diversity filtering.
Uses cached artist genres from database (populated during discovery scan).
Supports both parent genres (e.g., "Electronic/Dance") and specific genres (e.g., "house").
The genre keyword match runs Python-side over the JSON-encoded artist_genres
column, so this method overfetches via the shared selector then filters.
"""
try:
# Determine active source if not specified
active_source = source or self._get_active_source()
active_source = source or self._get_active_source()
with self.database._get_connection() as conn:
cursor = conn.cursor()
# Build keyword list: parent genre expands to all child keywords;
# specific genre uses its own name for partial matching.
if genre in self.GENRE_MAPPING:
search_keywords = [k.lower() for k in self.GENRE_MAPPING[genre]]
logger.info(f"Matching parent genre '{genre}' with {len(search_keywords)} child keywords")
else:
search_keywords = [genre.lower()]
logger.info(f"Matching specific genre '{genre}' with partial matching")
# Get all tracks with genres from discovery pool, filtered by source
cursor.execute("""
SELECT
spotify_track_id,
itunes_track_id,
track_name,
artist_name,
album_name,
album_cover_url,
duration_ms,
popularity,
artist_genres,
track_data_json,
source
FROM discovery_pool
WHERE artist_genres IS NOT NULL
AND source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
""", (active_source,))
rows = cursor.fetchall()
# Pull every source row with non-null artist_genres through the shared
# selector (gets the ID gate + blacklist filter for free). Cap at a high
# bound — the Python keyword filter narrows the result drastically.
candidate_tracks = self._select_discovery_tracks(
source=active_source,
extra_where="AND artist_genres IS NOT NULL",
order_by="RANDOM()",
fetch_limit=1_000_000,
extra_columns=('artist_genres',),
)
# Determine if this is a parent genre or specific genre
is_parent_genre = genre in self.GENRE_MAPPING
search_keywords = []
if is_parent_genre:
# Use all child genre keywords for matching
search_keywords = self.GENRE_MAPPING[genre]
logger.info(f"Matching parent genre '{genre}' with {len(search_keywords)} child keywords")
else:
# Use the genre name itself for partial matching
search_keywords = [genre.lower()]
logger.info(f"Matching specific genre '{genre}' with partial matching")
# Filter tracks that match the genre
matching_tracks = []
for row in rows:
try:
artist_genres_json = row['artist_genres']
if artist_genres_json:
genres = json.loads(artist_genres_json)
# Check if any artist genre matches any search keyword
genre_match = False
for artist_genre in genres:
artist_genre_lower = artist_genre.lower()
for keyword in search_keywords:
if keyword in artist_genre_lower:
genre_match = True
break
if genre_match:
break
if genre_match:
matching_tracks.append(self._build_track_dict(row, active_source))
except Exception as e:
logger.debug(f"Error parsing genres for track: {e}")
continue
if not matching_tracks:
logger.warning(f"No tracks found for genre: {genre}")
return []
# Shuffle before limiting for better variety
random.shuffle(matching_tracks)
# Limit to 10x for diversity filtering
all_tracks = matching_tracks[:limit * 10] if len(matching_tracks) > limit * 10 else matching_tracks
if not all_tracks:
return []
# Apply adaptive diversity filtering (relaxed for genres)
unique_artists = len(set(track['artist_name'] for track in all_tracks))
if unique_artists >= 20:
max_per_album = 3
max_per_artist = 5
elif unique_artists >= 10:
max_per_album = 4
max_per_artist = 10
elif unique_artists >= 5:
max_per_album = 6
max_per_artist = 15
else:
# Very limited artist pool - be more lenient
max_per_album = 8
max_per_artist = 25
logger.info(f"Genre '{genre}' has {unique_artists} artists, {len(all_tracks)} total tracks - limits: {max_per_album}/album, {max_per_artist}/artist")
# Shuffle and apply diversity
random.shuffle(all_tracks)
tracks_by_album = {}
tracks_by_artist = {}
diverse_tracks = []
for track in all_tracks:
album = track['album_name']
artist = track['artist_name']
album_count = tracks_by_album.get(album, 0)
artist_count = tracks_by_artist.get(artist, 0)
if album_count < max_per_album and artist_count < max_per_artist:
diverse_tracks.append(track)
tracks_by_album[album] = album_count + 1
tracks_by_artist[artist] = artist_count + 1
if len(diverse_tracks) >= limit:
break
logger.info(f"Found {len(diverse_tracks)} tracks for genre '{genre}'")
return diverse_tracks[:limit]
except Exception as e:
logger.error(f"Error getting genre playlist for {genre}: {e}")
if not candidate_tracks:
logger.warning(f"No tracks with genre data found for source: {active_source}")
return []
# `_build_track_dict` stashes the raw `artist_genres` column under
# `_artist_genres_raw` (since we requested it via `extra_columns`),
# so the keyword match can run without re-querying.
matching_tracks = [
track for track in candidate_tracks
if self._genre_matches(track.get('_artist_genres_raw'), search_keywords)
]
if not matching_tracks:
logger.warning(f"No tracks found for genre: {genre}")
return []
random.shuffle(matching_tracks)
# Cap candidate set at 10x limit for diversity filtering headroom.
all_tracks = matching_tracks[:limit * 10] if len(matching_tracks) > limit * 10 else matching_tracks
max_per_album, max_per_artist = self._compute_adaptive_diversity_limits(all_tracks, relaxed=True)
unique_artists = len(set(t['artist_name'] for t in all_tracks))
logger.info(
f"Genre '{genre}' has {unique_artists} artists, {len(all_tracks)} total tracks - "
f"limits: {max_per_album}/album, {max_per_artist}/artist"
)
random.shuffle(all_tracks)
diverse_tracks = self._apply_diversity_filter(
all_tracks,
max_per_album=max_per_album,
max_per_artist=max_per_artist,
limit=limit,
)
logger.info(f"Found {len(diverse_tracks)} tracks for genre '{genre}'")
return diverse_tracks
# ========================================
# DISCOVERY POOL PLAYLISTS
# ========================================
def get_popular_picks(self, limit: int = 50) -> List[Dict]:
"""Get high popularity tracks from discovery pool with diversity (max 2 tracks per album/artist)"""
# Determine active source
"""Get high popularity tracks from discovery pool with diversity (max 2 per album, 3 per artist)."""
active_source = self._get_active_source()
try:
with self.database._get_connection() as conn:
cursor = conn.cursor()
# Over-fetch 3x so the diversity filter has room to spread albums/artists.
all_tracks = self._select_discovery_tracks(
source=active_source,
extra_where="AND popularity >= 60",
order_by="popularity DESC, RANDOM()",
fetch_limit=limit * 3,
)
# Get more tracks than needed to allow for filtering, filtered by source
cursor.execute("""
SELECT
spotify_track_id,
itunes_track_id,
track_name,
artist_name,
album_name,
album_cover_url,
duration_ms,
popularity,
track_data_json,
source
FROM discovery_pool
WHERE popularity >= 60 AND source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
ORDER BY popularity DESC, RANDOM()
LIMIT ?
""", (active_source, limit * 3))
diverse_tracks = self._apply_diversity_filter(
all_tracks,
max_per_album=2,
max_per_artist=3,
limit=limit,
)
rows = cursor.fetchall()
all_tracks = [self._build_track_dict(row, active_source) for row in rows]
# Apply diversity constraint: max 2 tracks per album, max 3 per artist
tracks_by_album = {}
tracks_by_artist = {}
diverse_tracks = []
for track in all_tracks:
album = track['album_name']
artist = track['artist_name']
# Count current tracks for this album/artist
album_count = tracks_by_album.get(album, 0)
artist_count = tracks_by_artist.get(artist, 0)
# Apply limits: max 2 per album, max 3 per artist
if album_count < 2 and artist_count < 3:
diverse_tracks.append(track)
tracks_by_album[album] = album_count + 1
tracks_by_artist[artist] = artist_count + 1
if len(diverse_tracks) >= limit:
break
logger.info(f"Popular Picks ({active_source}): Selected {len(diverse_tracks)} tracks with diversity")
return diverse_tracks[:limit]
except Exception as e:
logger.error(f"Error getting popular picks: {e}")
return []
logger.info(f"Popular Picks ({active_source}): selected {len(diverse_tracks)} tracks with diversity")
return diverse_tracks
def get_hidden_gems(self, limit: int = 50) -> List[Dict]:
"""Get low popularity (underground/indie) tracks from discovery pool"""
# Determine active source
"""Get low-popularity (underground/indie) tracks from discovery pool."""
active_source = self._get_active_source()
try:
with self.database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
spotify_track_id,
itunes_track_id,
track_name,
artist_name,
album_name,
album_cover_url,
duration_ms,
popularity,
track_data_json,
source
FROM discovery_pool
WHERE popularity < 40 AND source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
ORDER BY RANDOM()
LIMIT ?
""", (active_source, limit))
rows = cursor.fetchall()
return [self._build_track_dict(row, active_source) for row in rows]
except Exception as e:
logger.error(f"Error getting hidden gems: {e}")
return []
tracks = self._select_discovery_tracks(
source=active_source,
extra_where="AND popularity < 40",
order_by="RANDOM()",
fetch_limit=limit,
)
logger.info(f"Hidden Gems ({active_source}): selected {len(tracks)} tracks")
return tracks
def get_discovery_shuffle(self, limit: int = 50) -> List[Dict]:
"""
@ -602,53 +713,24 @@ class PersonalizedPlaylistsService:
Different every time you call it!
"""
# Determine active source
active_source = self._get_active_source()
try:
with self.database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT
spotify_track_id,
itunes_track_id,
track_name,
artist_name,
album_name,
album_cover_url,
duration_ms,
popularity,
track_data_json,
source
FROM discovery_pool
WHERE source = ?
AND LOWER(artist_name) NOT IN (SELECT LOWER(artist_name) FROM discovery_artist_blacklist)
ORDER BY RANDOM()
LIMIT ?
""", (active_source, limit))
rows = cursor.fetchall()
return [self._build_track_dict(row, active_source) for row in rows]
except Exception as e:
logger.error(f"Error getting discovery shuffle: {e}")
return []
tracks = self._select_discovery_tracks(
source=active_source,
order_by="RANDOM()",
fetch_limit=limit,
)
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) - your reliable go-tos.
NOTE: This requires library tracks to have Spotify metadata which may not be available.
Returns empty list if schema incompatible.
"""
try:
logger.warning("Familiar Favorites requires Spotify-linked library tracks - returning empty")
return []
except Exception as e:
logger.error(f"Error getting familiar favorites: {e}")
return []
"""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

@ -0,0 +1,423 @@
"""Tests for the lifted PersonalizedPlaylistsService selectors and the
mandatory ID-validity gate every section must enforce.
Context: discover-page selection methods used to return tracks/albums
with all source IDs NULL the UI displayed them, the user clicked
download, the download silently failed because there was nothing to
look up. This test file pins the gate at the helper level so a future
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
import sqlite3
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from core.personalized_playlists import PersonalizedPlaylistsService
# ---------------------------------------------------------------------------
# Fake DB
# ---------------------------------------------------------------------------
class _FakeDatabase:
"""Wraps an in-memory sqlite connection so the service's
`database._get_connection()` calls work the same as in production.
The schema mirrors the real `discovery_pool` + `tracks` + `albums`
+ `artists` shape just enough for the selection methods to exercise.
"""
def __init__(self):
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
self._setup_schema()
def _setup_schema(self):
cursor = self._conn.cursor()
cursor.executescript("""
CREATE TABLE discovery_pool (
id INTEGER PRIMARY KEY,
source TEXT NOT NULL,
spotify_track_id TEXT,
itunes_track_id TEXT,
deezer_track_id TEXT,
track_name TEXT,
artist_name TEXT,
album_name TEXT,
album_cover_url TEXT,
duration_ms INTEGER,
popularity INTEGER,
release_date TEXT,
artist_genres TEXT,
track_data_json TEXT
);
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()
@contextmanager
def _get_connection(self):
# Match the production interface (`with database._get_connection() as conn`)
try:
yield self._conn
finally:
pass
def insert_discovery_track(self, **kwargs):
cols = ", ".join(kwargs.keys())
placeholders = ", ".join(["?"] * len(kwargs))
self._conn.execute(
f"INSERT INTO discovery_pool ({cols}) VALUES ({placeholders})",
tuple(kwargs.values()),
)
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 (?)",
(artist_name,),
)
self._conn.commit()
@pytest.fixture
def service():
"""Service with a fresh in-memory DB. `_get_active_source` patched
to return 'spotify' so every selector targets the same source."""
db = _FakeDatabase()
svc = PersonalizedPlaylistsService(db)
with patch.object(svc, '_get_active_source', return_value='spotify'):
yield svc, db
# ---------------------------------------------------------------------------
# `_select_discovery_tracks` — the helper everyone goes through
# ---------------------------------------------------------------------------
def test_discovery_helper_filters_null_id_rows(service):
svc, db = service
db.insert_discovery_track(
source='spotify', spotify_track_id='sp1', track_name='Has Spotify ID',
artist_name='A', album_name='A', popularity=50,
)
db.insert_discovery_track(
source='spotify', spotify_track_id=None, itunes_track_id=None,
track_name='No IDs', artist_name='B', album_name='B', popularity=50,
)
db.insert_discovery_track(
source='spotify', itunes_track_id='it1', track_name='Has iTunes ID',
artist_name='C', album_name='C', popularity=50,
)
tracks = svc._select_discovery_tracks(
source='spotify',
order_by='track_name',
fetch_limit=100,
)
names = sorted(t['track_name'] for t in tracks)
assert names == ['Has Spotify ID', 'Has iTunes ID']
def test_discovery_helper_filters_blacklisted_artists(service):
svc, db = service
db.insert_discovery_track(
source='spotify', spotify_track_id='sp1', track_name='Keep',
artist_name='Good Artist', album_name='X',
)
db.insert_discovery_track(
source='spotify', spotify_track_id='sp2', track_name='Drop',
artist_name='Bad Artist', album_name='X',
)
db.blacklist('bad artist')
tracks = svc._select_discovery_tracks(
source='spotify',
order_by='track_name',
fetch_limit=100,
)
assert [t['track_name'] for t in tracks] == ['Keep']
def test_discovery_helper_honors_source_filter(service):
svc, db = service
db.insert_discovery_track(
source='spotify', spotify_track_id='sp1', track_name='SP',
artist_name='A', album_name='X',
)
db.insert_discovery_track(
source='itunes', itunes_track_id='it1', track_name='IT',
artist_name='A', album_name='X',
)
tracks = svc._select_discovery_tracks(
source='spotify',
order_by='track_name',
fetch_limit=100,
)
assert [t['track_name'] for t in tracks] == ['SP']
def test_discovery_helper_honors_extra_where(service):
svc, db = service
db.insert_discovery_track(
source='spotify', spotify_track_id='sp1', track_name='Pop60',
artist_name='A', album_name='X', popularity=60,
)
db.insert_discovery_track(
source='spotify', spotify_track_id='sp2', track_name='Pop20',
artist_name='A', album_name='X', popularity=20,
)
tracks = svc._select_discovery_tracks(
source='spotify',
extra_where='AND popularity >= 50',
order_by='popularity DESC',
fetch_limit=100,
)
assert [t['track_name'] for t in tracks] == ['Pop60']
# ---------------------------------------------------------------------------
# Diversity filter
# ---------------------------------------------------------------------------
def test_diversity_filter_caps_per_album():
svc = PersonalizedPlaylistsService(_FakeDatabase())
tracks = [
{'track_name': f't{i}', 'artist_name': 'A', 'album_name': 'Album1'}
for i in range(10)
]
out = svc._apply_diversity_filter(
tracks, max_per_album=3, max_per_artist=10, limit=10,
)
assert len(out) == 3
def test_diversity_filter_caps_per_artist():
svc = PersonalizedPlaylistsService(_FakeDatabase())
tracks = [
{'track_name': f't{i}', 'artist_name': 'OnlyArtist', 'album_name': f'Album{i}'}
for i in range(10)
]
out = svc._apply_diversity_filter(
tracks, max_per_album=10, max_per_artist=2, limit=10,
)
assert len(out) == 2
def test_diversity_filter_stops_at_limit():
svc = PersonalizedPlaylistsService(_FakeDatabase())
tracks = [
{'track_name': f't{i}', 'artist_name': f'a{i}', 'album_name': f'b{i}'}
for i in range(20)
]
out = svc._apply_diversity_filter(
tracks, max_per_album=10, max_per_artist=10, limit=5,
)
assert len(out) == 5
# ---------------------------------------------------------------------------
# Adaptive diversity limits
# ---------------------------------------------------------------------------
def test_adaptive_limits_high_variety():
svc = PersonalizedPlaylistsService(_FakeDatabase())
tracks = [{'artist_name': f'a{i}'} for i in range(30)]
max_album, max_artist = svc._compute_adaptive_diversity_limits(tracks)
# High variety tier — strict limits
assert (max_album, max_artist) == (3, 5)
def test_adaptive_limits_low_variety():
svc = PersonalizedPlaylistsService(_FakeDatabase())
tracks = [{'artist_name': f'a{i % 3}'} for i in range(30)] # only 3 unique artists
max_album, max_artist = svc._compute_adaptive_diversity_limits(tracks)
# Low variety tier — much more lenient (matches existing decade-style limits)
assert max_album >= 4
assert max_artist >= 8
def test_adaptive_limits_relaxed_flag_loosens_genre_tier():
svc = PersonalizedPlaylistsService(_FakeDatabase())
# 15 unique artists triggers the moderate tier
tracks = [{'artist_name': f'a{i}'} for i in range(15)]
strict = svc._compute_adaptive_diversity_limits(tracks, relaxed=False)
relaxed = svc._compute_adaptive_diversity_limits(tracks, relaxed=True)
assert relaxed[1] >= strict[1]
# ---------------------------------------------------------------------------
# Public methods enforce the gate (smoke test on each)
# ---------------------------------------------------------------------------
def test_get_hidden_gems_filters_null_id_rows(service):
svc, db = service
db.insert_discovery_track(
source='spotify', spotify_track_id='sp1', track_name='Gem',
artist_name='A', album_name='X', popularity=20,
)
db.insert_discovery_track(
source='spotify', spotify_track_id=None, itunes_track_id=None,
track_name='Nogem', artist_name='A', album_name='X', popularity=20,
)
out = svc.get_hidden_gems(limit=10)
assert [t['track_name'] for t in out] == ['Gem']
def test_get_discovery_shuffle_filters_null_id_rows(service):
svc, db = service
db.insert_discovery_track(
source='spotify', spotify_track_id='sp1', track_name='Yes',
artist_name='A', album_name='X',
)
db.insert_discovery_track(
source='spotify', spotify_track_id=None, itunes_track_id=None,
track_name='No', artist_name='A', album_name='X',
)
out = svc.get_discovery_shuffle(limit=10)
assert [t['track_name'] for t in out] == ['Yes']
def test_get_popular_picks_filters_null_id_rows(service):
svc, db = service
db.insert_discovery_track(
source='spotify', spotify_track_id='sp1', track_name='Yes',
artist_name='A', album_name='X', popularity=80,
)
db.insert_discovery_track(
source='spotify', spotify_track_id=None, itunes_track_id=None,
track_name='No', artist_name='A', album_name='X', popularity=80,
)
out = svc.get_popular_picks(limit=10)
assert [t['track_name'] for t in out] == ['Yes']
def test_get_decade_playlist_filters_null_id_rows(service):
svc, db = service
db.insert_discovery_track(
source='spotify', spotify_track_id='sp1', track_name='Yes',
artist_name='A', album_name='X', release_date='2024-06-01',
)
db.insert_discovery_track(
source='spotify', spotify_track_id=None, itunes_track_id=None,
track_name='No', artist_name='A', album_name='X', release_date='2024-06-01',
)
out = svc.get_decade_playlist(2020, limit=10)
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']