Personalized playlists (2/N): all 8 generators wired through manager

Adds the per-kind generator modules and registers them with the
PlaylistKindRegistry so the manager's `refresh_playlist` can dispatch
to any of them.

Generators (each in its own module under
`core/personalized/generators/`):

Singletons (variant=''):
- hidden_gems       -> wraps service.get_hidden_gems
- discovery_shuffle -> wraps service.get_discovery_shuffle
- popular_picks    -> wraps service.get_popular_picks

Variant-bearing kinds:
- time_machine      -> variant = decade label ('1980s', '1990s', ...).
                       Variant resolver returns 7 standard decades.
                       Generator parses '1980s' -> 1980 + delegates
                       to service.get_decade_playlist.
- genre_playlist    -> variant = URL-safe genre key
                       ('electronic_dance', 'hip_hop_rap', ...).
                       Resolver normalizes parent-genre keys from
                       service.GENRE_MAPPING; free-form keywords
                       pass through to service.get_genre_playlist.
- daily_mix         -> variant = top-genre rank ('1' / '2' / '3' / '4').
                       Generator looks up user's Nth-ranked library
                       genre and returns discovery picks within it.
                       Library half (was a stub returning []) is
                       intentionally dropped: tracks table has no
                       source IDs, so library rows can't sync. Fixed
                       the stub to return [] cleanly without the
                       misleading log warning.
- fresh_tape        -> Spotify Release Radar. Reads curated track
                       IDs from discovery_curated_playlists (tries
                       'release_radar_<source>' first, falls back to
                       'release_radar') and hydrates against the
                       discovery pool.
- archives          -> Spotify Discover Weekly. Same hydration path
                       as fresh_tape but uses 'discovery_weekly'.
- seasonal_mix      -> variant = season key ('halloween' / 'christmas'
                       / 'valentines' / 'summer' / 'spring' / 'autumn').
                       Reads curated IDs via SeasonalDiscoveryService
                       then hydrates from seasonal_tracks (which
                       carries full track_data_json).

Each module:
- Defines `generate(deps, variant, config) -> List[Track]`.
- Defines `SPEC = PlaylistKindSpec(...)` and registers it on import
  (idempotent — re-import safe via `if registry.get(...) is None`).
- For variant-bearing kinds, also defines `variant_resolver(deps)`.

Shared helpers in `_common.py`:
- `get_service(deps)` pulls the legacy
  `PersonalizedPlaylistsService` instance (deps.service or
  deps['service']).
- `coerce_tracks(rows)` runs each dict through `Track.from_dict`,
  tolerates None / non-list inputs.

Tests (50 new, total 85 across personalized subsystem):
- Singletons: registration + display name + dispatch + limit
  forwarding + empty/None tolerance + missing-deps error +
  dict-form deps acceptance (16 tests).
- Variants: variant_resolver listing + label parsing + invalid
  variant errors + parent-key normalization + free-form passthrough
  (13 tests).
- Curated/hybrid: daily_mix rank-to-genre resolution + rank-out-of-
  range empty + invalid-variant error; fresh_tape & archives
  hydration order + missing-id skip + source-specific-then-fallback
  key dispatch + limit + missing-database-dep error; seasonal_mix
  curated-id hydration order + missing-id skip + JSON round-trip +
  empty-curated empty + limit + missing-service error (21 tests).

3304+ tests pass. No regression on existing 62 personalized tests.
pull/613/head
Broque Thomas 1 week ago
parent 79224ed294
commit 53284ee7c8

@ -0,0 +1,27 @@
"""Per-kind generators for the personalized-playlists subsystem.
Each module in this subpackage:
1. Defines a generator function ``generate(deps, variant, config)``
that returns a ``List[Track]``.
2. Calls ``get_registry().register(spec)`` at import time so the
manager auto-discovers it.
The legacy ``core.personalized_playlists.PersonalizedPlaylistsService``
keeps its existing implementations the wrappers in this package
just adapt the call surface (`PlaylistConfig` method kwargs) and
coerce results into ``Track`` instances.
To register every generator, import this package `from
core.personalized import generators` typically done once at
application startup."""
# Importing each module triggers its registration side-effect.
from core.personalized.generators import hidden_gems # noqa: F401
from core.personalized.generators import discovery_shuffle # noqa: F401
from core.personalized.generators import popular_picks # noqa: F401
from core.personalized.generators import time_machine # noqa: F401
from core.personalized.generators import genre_playlist # noqa: F401
from core.personalized.generators import daily_mix # noqa: F401
from core.personalized.generators import fresh_tape # noqa: F401
from core.personalized.generators import archives # noqa: F401
from core.personalized.generators import seasonal_mix # noqa: F401

@ -0,0 +1,37 @@
"""Shared helpers for personalized-playlist generators.
Each per-kind generator module is small + mechanical it pulls the
legacy ``PersonalizedPlaylistsService`` instance off the deps object
and calls the matching method, then coerces results. This module
holds the bits every generator reuses so we don't repeat them
five times."""
from __future__ import annotations
from typing import Any, List
from core.personalized.types import Track
def get_service(deps: Any):
"""Pull the ``PersonalizedPlaylistsService`` instance from deps.
Generators access the service via ``deps.service``. Tests can
pass a fake deps namespace with a ``service`` attribute that
returns a stub. Raises a clear error if the dep isn't wired."""
service = getattr(deps, 'service', None) or (deps.get('service') if isinstance(deps, dict) else None)
if service is None:
raise RuntimeError(
"Personalized generator deps missing `service` "
"(PersonalizedPlaylistsService instance). Wire it during "
"PersonalizedPlaylistManager construction."
)
return service
def coerce_tracks(rows: List[dict]) -> List[Track]:
"""Convert legacy generator output (list of dicts) into Track
instances. Tolerates None / non-list inputs by returning []."""
if not rows:
return []
return [Track.from_dict(row) for row in rows if isinstance(row, dict)]

@ -0,0 +1,35 @@
"""The Archives (Spotify Discover Weekly) generator.
Same shape as Fresh Tape read curated track-id list from
``discovery_curated_playlists`` under ``discovery_weekly_<source>``
(fallback ``discovery_weekly``), hydrate via discovery pool."""
from __future__ import annotations
from typing import Any, List
from core.personalized.generators.fresh_tape import _hydrate_curated
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'archives'
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
return _hydrate_curated(deps, 'discovery_weekly', config)
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='The Archives',
description='Your Spotify Discover Weekly — curated discovery picks.',
default_config=PlaylistConfig(limit=50, max_per_album=5, max_per_artist=10),
generator=generate,
requires_variant=False,
tags=['curated', 'spotify'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

@ -0,0 +1,83 @@
"""Daily Mix generator — top library genre → discovery picks.
Variant = rank position as a string ('1' / '2' / '3' / '4'). Each
mix tracks the user's Nth top library genre and returns discovery
picks within it. Top genres recompute at refresh time, so as the
library evolves a mix's underlying genre can shift -- the playlist
metadata records which genre was used at the most recent refresh
so the UI can label the mix accurately.
Note: previously this kind ambitiously promised 50% library + 50%
discovery. The library half was a stub (`tracks` table has no
source IDs to sync), so the new generator is discovery-only.
A future enhancement can backfill source IDs into library rows
and re-add the hybrid behavior."""
from __future__ import annotations
from typing import Any, List
from core.personalized.generators._common import coerce_tracks, get_service
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'daily_mix'
# Default rank set — UI surfaces 4 daily mixes by default.
_DEFAULT_RANKS = ('1', '2', '3', '4')
# Number of top library genres to consider when ranking.
_MAX_TOP_GENRES = 8
def _resolve_genre_for_rank(service, rank: int) -> str:
"""Look up the user's Nth-ranked top library genre. Returns the
genre key or '' when no genre at that rank.
Calls ``service.get_top_genres_from_library(limit=...)`` and
indexes the resulting (genre, count) tuples by 0-based rank.
"""
top = service.get_top_genres_from_library(limit=_MAX_TOP_GENRES) or []
if rank < 1 or rank > len(top):
return ''
pair = top[rank - 1]
if not pair:
return ''
# `top` is List[Tuple[str, int]] per service signature.
return pair[0] if isinstance(pair, (tuple, list)) else str(pair)
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
service = get_service(deps)
try:
rank = int(variant)
except (TypeError, ValueError) as exc:
raise ValueError(f"Daily Mix variant {variant!r} must be a rank int") from exc
genre = _resolve_genre_for_rank(service, rank)
if not genre:
# User's library doesn't have enough genres for this rank.
return []
rows = service.get_genre_playlist(genre=genre, limit=config.limit)
return coerce_tracks(rows)
def variant_resolver(deps: Any) -> List[str]:
"""Return the standard rank set."""
return list(_DEFAULT_RANKS)
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Daily Mix {variant}',
description='Personalized mix based on your top library genres. One mix per top genre rank.',
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
generator=generate,
variant_resolver=variant_resolver,
requires_variant=True,
tags=['discovery', 'personalized'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

@ -0,0 +1,33 @@
"""Discovery Shuffle generator — pure-random discovery pool exploration."""
from __future__ import annotations
from typing import Any, List
from core.personalized.generators._common import coerce_tracks, get_service
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'discovery_shuffle'
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
service = get_service(deps)
rows = service.get_discovery_shuffle(limit=config.limit)
return coerce_tracks(rows)
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Discovery Shuffle',
description='Pure random shuffle from the discovery pool — different every refresh.',
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=2),
generator=generate,
requires_variant=False,
tags=['discovery'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

@ -0,0 +1,119 @@
"""Fresh Tape (Spotify Release Radar) generator.
Reads the curated track-id list cached in ``discovery_curated_playlists``
under ``release_radar_<source>`` (with fallback to ``release_radar``)
and hydrates each ID against the discovery pool to produce full Track
records. The Spotify enrichment worker is responsible for keeping the
curated list fresh this generator is just a read-and-hydrate path."""
from __future__ import annotations
import json
from typing import Any, List
from core.personalized.generators._common import coerce_tracks
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'fresh_tape'
def _hydrate_curated(deps: Any, curated_type_prefix: str, config: PlaylistConfig) -> List[Track]:
"""Shared body for Fresh Tape + Archives — pulls the cached IDs
from discovery_curated_playlists and hydrates them via the live
discovery pool. Returns a Track list trimmed to ``config.limit``."""
# Allow tests to inject a fake db / service; production flow gets
# them from the manager's deps.
db = getattr(deps, 'database', None) or (deps.get('database') if isinstance(deps, dict) else None)
if db is None:
raise RuntimeError("Curated-playlist generator deps missing `database`")
profile_id = _resolve_profile_id(deps)
active_source = _resolve_active_source(deps)
# Try source-specific then generic, mirrors web_server endpoint behavior.
curated_ids = (
db.get_curated_playlist(f'{curated_type_prefix}_{active_source}', profile_id=profile_id)
or db.get_curated_playlist(curated_type_prefix, profile_id=profile_id)
or []
)
if not curated_ids:
return []
pool_rows = db.get_discovery_pool_tracks(
limit=5000, new_releases_only=False,
source=active_source, profile_id=profile_id,
)
by_id = {}
for t in pool_rows:
if active_source == 'spotify' and getattr(t, 'spotify_track_id', None):
by_id[t.spotify_track_id] = t
elif active_source == 'deezer' and getattr(t, 'deezer_track_id', None):
by_id[t.deezer_track_id] = t
elif active_source == 'itunes' and getattr(t, 'itunes_track_id', None):
by_id[t.itunes_track_id] = t
tracks: List[Track] = []
for tid in curated_ids:
candidate = by_id.get(tid)
if candidate is None:
continue
# The pool track is a row-like object; coerce to dict for
# Track.from_dict's existing tolerance.
td = getattr(candidate, 'track_data_json', None)
if isinstance(td, str):
try:
td = json.loads(td)
except (ValueError, TypeError):
td = None
track_dict = {
'spotify_track_id': getattr(candidate, 'spotify_track_id', None),
'itunes_track_id': getattr(candidate, 'itunes_track_id', None),
'deezer_track_id': getattr(candidate, 'deezer_track_id', None),
'track_name': getattr(candidate, 'track_name', ''),
'artist_name': getattr(candidate, 'artist_name', ''),
'album_name': getattr(candidate, 'album_name', ''),
'album_cover_url': getattr(candidate, 'album_cover_url', None),
'duration_ms': getattr(candidate, 'duration_ms', 0),
'popularity': getattr(candidate, 'popularity', 0),
'track_data_json': td,
'source': getattr(candidate, 'source', active_source),
}
tracks.append(Track.from_dict(track_dict))
if len(tracks) >= config.limit:
break
return tracks
def _resolve_profile_id(deps: Any) -> int:
fn = getattr(deps, 'get_current_profile_id', None) or (
deps.get('get_current_profile_id') if isinstance(deps, dict) else None
)
return fn() if callable(fn) else 1
def _resolve_active_source(deps: Any) -> str:
fn = getattr(deps, 'get_active_discovery_source', None) or (
deps.get('get_active_discovery_source') if isinstance(deps, dict) else None
)
return fn() if callable(fn) else 'spotify'
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
return _hydrate_curated(deps, 'release_radar', config)
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Fresh Tape',
description='Your Spotify Release Radar — new releases from artists you follow.',
default_config=PlaylistConfig(limit=50, max_per_album=5, max_per_artist=10),
generator=generate,
requires_variant=False,
tags=['curated', 'spotify'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

@ -0,0 +1,78 @@
"""Genre Playlist generator — discovery picks within one genre.
Variant = either a parent-genre key from
``PersonalizedPlaylistsService.GENRE_MAPPING`` (e.g. ``'rock'``,
``'electronic_dance'``) or a specific child-genre keyword (e.g.
``'house'``). Stored variant is always normalized to lowercase
underscore-separated form so the UI and storage agree.
"""
from __future__ import annotations
from typing import Any, List
from core.personalized.generators._common import coerce_tracks, get_service
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'genre_playlist'
def _normalize_variant_to_genre_key(variant: str, service) -> str:
"""Resolve a variant string back into the genre identifier the
legacy service expects.
Service accepts both parent-genre KEYS from GENRE_MAPPING (e.g.
'Electronic/Dance', 'Hip Hop/Rap') and free-form keywords.
The URL-safe variant we store is the parent key with `/` replaced
by `_` and lowercased e.g. 'electronic_dance'. This helper
inverts that mapping."""
if not variant:
raise ValueError('Genre playlist requires a variant')
# Build a once-computed lookup of normalized → original parent key.
mapping = getattr(service, 'GENRE_MAPPING', {})
for parent_key in mapping.keys():
normalized = parent_key.lower().replace('/', '_').replace(' ', '_')
if normalized == variant.lower():
return parent_key
# Fall through: treat the variant as a free-form keyword (the
# legacy service handles partial matching for those).
return variant
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
service = get_service(deps)
genre_key = _normalize_variant_to_genre_key(variant, service)
rows = service.get_genre_playlist(genre=genre_key, limit=config.limit)
return coerce_tracks(rows)
def variant_resolver(deps: Any) -> List[str]:
"""Return the URL-safe variant for every parent genre defined on
the service. Specific (free-form) genre variants aren't enumerated
they're created on demand when the user requests a custom one."""
service = get_service(deps)
mapping = getattr(service, 'GENRE_MAPPING', {})
return [
parent.lower().replace('/', '_').replace(' ', '_')
for parent in mapping.keys()
]
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Genre — {variant}',
description='Discovery picks within one genre. Supports parent-genre families + free-form genre keywords.',
default_config=PlaylistConfig(limit=50, max_per_album=3, max_per_artist=5),
generator=generate,
variant_resolver=variant_resolver,
requires_variant=True,
tags=['genre'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

@ -0,0 +1,42 @@
"""Hidden Gems generator — low-popularity tracks from discovery pool.
Wraps ``PersonalizedPlaylistsService.get_hidden_gems`` so the
existing source-aware popularity threshold + diversity filter
behavior is preserved verbatim. The user-tweakable knobs that
arrive via ``PlaylistConfig`` (limit) flow through; future config
options (popularity_max override, exclude_recent_days) get layered
on the wrapper without changing the legacy implementation."""
from __future__ import annotations
from typing import Any, List
from core.personalized.generators._common import coerce_tracks, get_service
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'hidden_gems'
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
service = get_service(deps)
rows = service.get_hidden_gems(limit=config.limit)
return coerce_tracks(rows)
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Hidden Gems',
description='Low-popularity discovery picks — underground / indie tracks you probably haven\'t heard.',
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
generator=generate,
requires_variant=False,
tags=['discovery'],
)
# Register at import time so the manager auto-discovers this kind.
# Re-import (e.g. test reloads) is tolerated: only register if absent.
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

@ -0,0 +1,33 @@
"""Popular Picks generator — high-popularity discovery pool picks."""
from __future__ import annotations
from typing import Any, List
from core.personalized.generators._common import coerce_tracks, get_service
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'popular_picks'
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
service = get_service(deps)
rows = service.get_popular_picks(limit=config.limit)
return coerce_tracks(rows)
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Popular Picks',
description='High-popularity tracks from the discovery pool — what most people are listening to.',
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
generator=generate,
requires_variant=False,
tags=['discovery'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

@ -0,0 +1,136 @@
"""Seasonal Mix generator (variant = season key).
Variant = season key from ``SEASONAL_CONFIG`` (``'halloween'`` /
``'christmas'`` / ``'valentines'`` / ``'summer'`` / ``'spring'`` /
``'autumn'``). One playlist per season user picks which seasons
to enable; idle seasons can stay un-refreshed until their active
period.
Reads curated track IDs from ``curated_seasonal_playlists`` (via
``SeasonalDiscoveryService.get_curated_seasonal_playlist``) and
hydrates them against ``seasonal_tracks`` (which carries full
metadata including ``track_data_json`` for sync-ready downstream
use)."""
from __future__ import annotations
import json
from typing import Any, List
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'seasonal_mix'
def _resolve_seasonal_service(deps: Any):
"""Pull the SeasonalDiscoveryService instance from deps."""
svc = getattr(deps, 'seasonal_service', None) or (
deps.get('seasonal_service') if isinstance(deps, dict) else None
)
if svc is None:
raise RuntimeError(
"Seasonal mix generator deps missing `seasonal_service` "
"(SeasonalDiscoveryService instance)."
)
return svc
def _resolve_database(deps: Any):
db = getattr(deps, 'database', None) or (
deps.get('database') if isinstance(deps, dict) else None
)
if db is None:
raise RuntimeError("Seasonal mix generator deps missing `database`")
return db
def _resolve_active_source(deps: Any) -> str:
fn = getattr(deps, 'get_active_discovery_source', None) or (
deps.get('get_active_discovery_source') if isinstance(deps, dict) else None
)
return fn() if callable(fn) else 'spotify'
def _hydrate_seasonal_tracks(db, season_key: str, source: str, track_ids: List[str]) -> List[Track]:
"""Look up the seasonal_tracks rows for the given IDs."""
if not track_ids:
return []
placeholders = ','.join('?' * len(track_ids))
with db._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
f"""
SELECT spotify_track_id, track_name, artist_name, album_name,
album_cover_url, duration_ms, popularity, track_data_json
FROM seasonal_tracks
WHERE season_key = ? AND source = ?
AND spotify_track_id IN ({placeholders})
""",
(season_key, source, *track_ids),
)
rows = cursor.fetchall()
by_id = {}
for r in rows:
if hasattr(r, 'keys'):
r = dict(r)
else:
r = dict(zip(
('spotify_track_id', 'track_name', 'artist_name', 'album_name',
'album_cover_url', 'duration_ms', 'popularity', 'track_data_json'),
r,
))
td = r.get('track_data_json')
if isinstance(td, str):
try:
td = json.loads(td)
except (ValueError, TypeError):
td = None
r['track_data_json'] = td
r['source'] = source
by_id[r['spotify_track_id']] = r
# Preserve curated order.
return [
Track.from_dict(by_id[tid])
for tid in track_ids
if tid in by_id
]
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
if not variant:
raise ValueError('Seasonal Mix requires a season variant')
seasonal_service = _resolve_seasonal_service(deps)
db = _resolve_database(deps)
source = _resolve_active_source(deps)
track_ids = seasonal_service.get_curated_seasonal_playlist(variant, source=source) or []
tracks = _hydrate_seasonal_tracks(db, variant, source, track_ids)
return tracks[:config.limit]
def variant_resolver(deps: Any) -> List[str]:
"""Return every season key from SEASONAL_CONFIG."""
try:
from core.seasonal_discovery import SEASONAL_CONFIG
except Exception:
return []
return list(SEASONAL_CONFIG.keys())
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Seasonal — {variant}',
description='Holiday / season-themed picks. One playlist per season; user enables which to track.',
default_config=PlaylistConfig(limit=50, max_per_album=2, max_per_artist=3),
generator=generate,
variant_resolver=variant_resolver,
requires_variant=True,
tags=['curated', 'seasonal'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

@ -0,0 +1,66 @@
"""Time Machine generator — by-decade discovery picks.
Variant = decade label like ``'1980s'`` / ``'1990s'`` / ``'2000s'``.
The variant resolver returns the standard decade set; users see one
playlist per decade (each independently configurable / refreshable).
"""
from __future__ import annotations
from typing import Any, List
from core.personalized.generators._common import coerce_tracks, get_service
from core.personalized.specs import PlaylistKindSpec, get_registry
from core.personalized.types import PlaylistConfig, Track
KIND = 'time_machine'
# Standard decades the UI exposes. Adjust here when adding eras.
_DEFAULT_DECADES = ('1960s', '1970s', '1980s', '1990s', '2000s', '2010s', '2020s')
def _decade_to_year(variant: str) -> int:
"""'1980s' -> 1980. Tolerates ' 1980 ', '1980'.
Raises ValueError for anything that doesn't look like a decade
label so the manager surfaces a clear error instead of generating
garbage."""
cleaned = (variant or '').strip().rstrip('sS')
try:
year = int(cleaned)
except ValueError as exc:
raise ValueError(f"Time Machine variant {variant!r} not a decade label") from exc
if year < 1900 or year > 2100:
raise ValueError(f"Time Machine variant {variant!r} out of range")
return year
def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]:
service = get_service(deps)
decade_year = _decade_to_year(variant)
rows = service.get_decade_playlist(decade=decade_year, limit=config.limit)
return coerce_tracks(rows)
def variant_resolver(deps: Any) -> List[str]:
"""Return the standard decade set. Future enhancement: filter to
decades that actually have data in the discovery pool."""
return list(_DEFAULT_DECADES)
SPEC = PlaylistKindSpec(
kind=KIND,
name_template='Time Machine — {variant}',
description='Tracks from a specific decade. One playlist per decade.',
default_config=PlaylistConfig(limit=100, max_per_album=3, max_per_artist=5),
generator=generate,
variant_resolver=variant_resolver,
requires_variant=True,
tags=['time'],
)
if get_registry().get(KIND) is None:
get_registry().register(SPEC)

@ -779,19 +779,21 @@ class PersonalizedPlaylistsService:
}
def _get_library_tracks_by_category(self, category: str, limit: int) -> List[Dict]:
"""Library tracks intentionally excluded from daily mixes.
The legacy ambition was 50% library + 50% discovery, but the
``tracks`` table doesn't carry source IDs (no
``spotify_track_id`` / ``itunes_track_id`` / ``deezer_track_id``
column) so library rows can't flow through the same
sync / wishlist pipeline that discovery tracks do. Returning
them here would produce un-syncable, un-downloadable phantom
entries.
Returns ``[]`` so callers compose with ``_get_discovery_tracks_by_category``
for a discovery-only mix. A future PR can backfill source IDs
into library rows and lift this restriction.
"""
Get tracks from library matching genre or artist
NOTE: This requires library tracks to have Spotify metadata which may not be available.
Returns empty list if schema incompatible.
"""
try:
logger.warning("Library tracks by category requires Spotify-linked library - returning empty")
return []
except Exception as e:
logger.error(f"Error getting library tracks by category: {e}")
return []
return []
def _get_discovery_tracks_by_category(self, category: str, limit: int) -> List[Dict]:
"""Get tracks from discovery pool matching genre or artist"""

@ -0,0 +1,316 @@
"""Boundary tests for the curated / hybrid personalized generators
(`daily_mix`, `fresh_tape`, `archives`, `seasonal_mix`)."""
from __future__ import annotations
import sqlite3
from types import SimpleNamespace
from typing import Any, List
from unittest.mock import MagicMock
import pytest
from core.personalized.generators import archives as _arch_mod
from core.personalized.generators import daily_mix as _dm_mod
from core.personalized.generators import fresh_tape as _ft_mod
from core.personalized.generators import seasonal_mix as _sm_mod
from core.personalized.specs import get_registry
from core.personalized.types import PlaylistConfig
# ─── daily_mix ───────────────────────────────────────────────────────
class _DailyMixService:
"""Stub PersonalizedPlaylistsService for daily_mix tests."""
GENRE_MAPPING = {}
def __init__(self, top_genres=None, genre_tracks=None):
self._top = top_genres or []
self._tracks = genre_tracks or {}
self.calls: List[dict] = []
def get_top_genres_from_library(self, limit):
self.calls.append({'method': 'get_top_genres_from_library', 'limit': limit})
return self._top
def get_genre_playlist(self, genre, limit, **kw):
self.calls.append({'method': 'get_genre_playlist', 'genre': genre, 'limit': limit})
return self._tracks.get(genre, [])
class TestDailyMix:
def test_registered(self):
spec = get_registry().get('daily_mix')
assert spec is not None
assert spec.requires_variant is True
def test_variant_resolver_returns_ranks(self):
spec = get_registry().get('daily_mix')
ranks = spec.variant_resolver(SimpleNamespace(service=_DailyMixService()))
assert ranks == ['1', '2', '3', '4']
def test_resolves_rank_to_top_genre(self):
svc = _DailyMixService(
top_genres=[('Rock', 100), ('Pop', 80), ('Jazz', 30)],
genre_tracks={'Rock': [{'track_name': 'R', 'artist_name': 'A'}]},
)
out = _dm_mod.generate(SimpleNamespace(service=svc), '1', PlaylistConfig(limit=10))
assert len(out) == 1
assert out[0].track_name == 'R'
# Service called for top-genre lookup + genre playlist.
assert {c['method'] for c in svc.calls} == {
'get_top_genres_from_library', 'get_genre_playlist',
}
def test_rank_beyond_top_returns_empty(self):
svc = _DailyMixService(top_genres=[('Rock', 100)]) # only 1 top genre
out = _dm_mod.generate(SimpleNamespace(service=svc), '4', PlaylistConfig())
assert out == []
def test_invalid_variant_raises(self):
deps = SimpleNamespace(service=_DailyMixService())
with pytest.raises(ValueError, match='must be a rank int'):
_dm_mod.generate(deps, 'abc', PlaylistConfig())
# ─── fresh_tape / archives shared shape ─────────────────────────────
class _StubPoolTrack:
def __init__(self, sid, name='T', artist='A', source='spotify'):
self.spotify_track_id = sid
self.itunes_track_id = None
self.deezer_track_id = None
self.track_name = name
self.artist_name = artist
self.album_name = 'Album'
self.album_cover_url = None
self.duration_ms = 200000
self.popularity = 50
self.track_data_json = None
self.source = source
class _CuratedDB:
def __init__(self, curated_ids=None, pool_tracks=None):
self.curated_ids = curated_ids or []
self.pool_tracks = pool_tracks or []
self.requested_keys: List[str] = []
def get_curated_playlist(self, key, profile_id=1):
self.requested_keys.append(key)
return list(self.curated_ids)
def get_discovery_pool_tracks(self, **kwargs):
return list(self.pool_tracks)
def _curated_deps(db):
return SimpleNamespace(
database=db,
get_current_profile_id=lambda: 1,
get_active_discovery_source=lambda: 'spotify',
)
class TestFreshTape:
def test_registered(self):
spec = get_registry().get('fresh_tape')
assert spec is not None
assert spec.requires_variant is False
assert spec.display_name('') == 'Fresh Tape'
def test_returns_empty_when_no_curated_ids(self):
db = _CuratedDB(curated_ids=[])
out = _ft_mod.generate(_curated_deps(db), '', PlaylistConfig())
assert out == []
def test_hydrates_curated_ids_from_pool(self):
db = _CuratedDB(
curated_ids=['sp-1', 'sp-2', 'sp-missing'],
pool_tracks=[
_StubPoolTrack('sp-1', name='Song1', artist='Artist1'),
_StubPoolTrack('sp-2', name='Song2', artist='Artist2'),
],
)
out = _ft_mod.generate(_curated_deps(db), '', PlaylistConfig())
# Missing IDs silently skipped; order preserved.
assert [t.track_name for t in out] == ['Song1', 'Song2']
def test_tries_source_specific_then_fallback_key(self):
# First lookup (source-specific) returns []; second (generic) returns IDs.
class _DB:
def __init__(self):
self.calls = []
self.responses = {
'release_radar_spotify': [],
'release_radar': ['sp-1'],
}
def get_curated_playlist(self, key, profile_id=1):
self.calls.append(key)
return self.responses.get(key, [])
def get_discovery_pool_tracks(self, **kw):
return [_StubPoolTrack('sp-1', name='Hit')]
db = _DB()
out = _ft_mod.generate(_curated_deps(db), '', PlaylistConfig())
assert db.calls == ['release_radar_spotify', 'release_radar']
assert len(out) == 1
def test_respects_limit(self):
db = _CuratedDB(
curated_ids=[f'sp-{i}' for i in range(20)],
pool_tracks=[_StubPoolTrack(f'sp-{i}', name=f'T{i}') for i in range(20)],
)
out = _ft_mod.generate(_curated_deps(db), '', PlaylistConfig(limit=5))
assert len(out) == 5
def test_missing_database_dep_raises(self):
with pytest.raises(RuntimeError, match='missing `database`'):
_ft_mod.generate(SimpleNamespace(), '', PlaylistConfig())
class TestArchives:
def test_registered(self):
spec = get_registry().get('archives')
assert spec is not None
assert spec.display_name('') == 'The Archives'
def test_uses_discovery_weekly_curated_key(self):
db = _CuratedDB(
curated_ids=['sp-1'],
pool_tracks=[_StubPoolTrack('sp-1', name='Discover')],
)
_arch_mod.generate(_curated_deps(db), '', PlaylistConfig())
# Source-specific request fires first; fallback only fires
# when source-specific returns empty. Stub returns IDs on
# every call, so only the first key gets queried.
assert db.requested_keys[0] == 'discovery_weekly_spotify'
# ─── seasonal_mix ───────────────────────────────────────────────────
class _SeasonalService:
def __init__(self, track_ids):
self.track_ids = track_ids
def get_curated_seasonal_playlist(self, season_key, source=None):
return list(self.track_ids)
@pytest.fixture
def seasonal_db(tmp_path):
"""Real sqlite DB with seasonal_tracks rows for hydration."""
p = str(tmp_path / 'seasonal.db')
conn = sqlite3.connect(p)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE seasonal_tracks (
id INTEGER PRIMARY KEY,
season_key TEXT, source TEXT,
spotify_track_id TEXT, track_name TEXT, artist_name TEXT,
album_name TEXT, album_cover_url TEXT,
duration_ms INTEGER, popularity INTEGER, track_data_json TEXT
)
""")
seed = [
('halloween', 'spotify', 'sp-1', 'Spooky', 'Ghost Band', 'Album1', None, 200000, 80, '{"id":"sp-1"}'),
('halloween', 'spotify', 'sp-2', 'Haunted', 'Ghost Band', 'Album2', None, 210000, 70, None),
('halloween', 'spotify', 'sp-extra', 'Extra', 'Other', 'Album3', None, 200000, 60, None),
]
cursor.executemany("""
INSERT INTO seasonal_tracks
(season_key, source, spotify_track_id, track_name, artist_name,
album_name, album_cover_url, duration_ms, popularity, track_data_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", seed)
conn.commit()
conn.close()
class _DB:
def __init__(self, path): self.path = path
def _get_connection(self):
c = sqlite3.connect(self.path)
c.row_factory = sqlite3.Row
return c
return _DB(p)
class TestSeasonalMix:
def test_registered(self):
spec = get_registry().get('seasonal_mix')
assert spec is not None
assert spec.requires_variant is True
def test_variant_resolver_returns_seasons(self):
spec = get_registry().get('seasonal_mix')
seasons = spec.variant_resolver(None)
assert 'halloween' in seasons
assert 'christmas' in seasons
def test_no_variant_raises(self, seasonal_db):
deps = SimpleNamespace(
seasonal_service=_SeasonalService(['sp-1']),
database=seasonal_db,
get_active_discovery_source=lambda: 'spotify',
)
with pytest.raises(ValueError, match='requires a season variant'):
_sm_mod.generate(deps, '', PlaylistConfig())
def test_hydrates_curated_ids_in_order(self, seasonal_db):
deps = SimpleNamespace(
seasonal_service=_SeasonalService(['sp-2', 'sp-1']),
database=seasonal_db,
get_active_discovery_source=lambda: 'spotify',
)
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig())
assert [t.track_name for t in out] == ['Haunted', 'Spooky']
def test_missing_track_id_silently_skipped(self, seasonal_db):
deps = SimpleNamespace(
seasonal_service=_SeasonalService(['sp-1', 'sp-not-in-db']),
database=seasonal_db,
get_active_discovery_source=lambda: 'spotify',
)
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig())
assert len(out) == 1
assert out[0].track_name == 'Spooky'
def test_track_data_json_round_trips(self, seasonal_db):
deps = SimpleNamespace(
seasonal_service=_SeasonalService(['sp-1']),
database=seasonal_db,
get_active_discovery_source=lambda: 'spotify',
)
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig())
# sp-1 had JSON; sp-2 had None.
assert out[0].track_data_json == {'id': 'sp-1'}
def test_empty_curated_returns_empty(self, seasonal_db):
deps = SimpleNamespace(
seasonal_service=_SeasonalService([]),
database=seasonal_db,
get_active_discovery_source=lambda: 'spotify',
)
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig())
assert out == []
def test_respects_limit(self, seasonal_db):
deps = SimpleNamespace(
seasonal_service=_SeasonalService(['sp-1', 'sp-2', 'sp-extra']),
database=seasonal_db,
get_active_discovery_source=lambda: 'spotify',
)
out = _sm_mod.generate(deps, 'halloween', PlaylistConfig(limit=2))
assert len(out) == 2
def test_missing_seasonal_service_raises(self):
deps = SimpleNamespace(database=object())
with pytest.raises(RuntimeError, match='missing `seasonal_service`'):
_sm_mod.generate(deps, 'halloween', PlaylistConfig())

@ -0,0 +1,147 @@
"""Boundary tests for the singleton-kind personalized generators
(`hidden_gems`, `discovery_shuffle`, `popular_picks`).
Each generator wraps the legacy
``PersonalizedPlaylistsService`` method 1:1, so the tests pin:
- registration side-effect at import
- generator forwards `config.limit` correctly
- empty / None / non-dict service output []
- tracks coerced through `Track.from_dict`
- missing service in deps raises a clear error"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, List
import pytest
# Importing each generator triggers registration as a side-effect.
from core.personalized.generators import discovery_shuffle as _ds_mod
from core.personalized.generators import hidden_gems as _hg_mod
from core.personalized.generators import popular_picks as _pp_mod
from core.personalized.specs import get_registry
from core.personalized.types import PlaylistConfig
class _StubService:
"""Records every call so tests can assert on dispatched limits."""
def __init__(self, return_value=None):
self.calls: List[dict] = []
self.return_value = return_value if return_value is not None else []
def get_hidden_gems(self, limit):
self.calls.append({'method': 'get_hidden_gems', 'limit': limit})
return self.return_value
def get_discovery_shuffle(self, limit):
self.calls.append({'method': 'get_discovery_shuffle', 'limit': limit})
return self.return_value
def get_popular_picks(self, limit):
self.calls.append({'method': 'get_popular_picks', 'limit': limit})
return self.return_value
def _deps(svc):
return SimpleNamespace(service=svc)
# ─── registration ────────────────────────────────────────────────────
class TestRegistration:
def test_hidden_gems_registered(self):
spec = get_registry().get('hidden_gems')
assert spec is not None
assert spec.kind == 'hidden_gems'
assert spec.requires_variant is False
assert spec.default_config.limit == 50
def test_discovery_shuffle_registered(self):
spec = get_registry().get('discovery_shuffle')
assert spec is not None
assert spec.requires_variant is False
def test_popular_picks_registered(self):
spec = get_registry().get('popular_picks')
assert spec is not None
assert spec.requires_variant is False
def test_display_names(self):
assert get_registry().get('hidden_gems').display_name('') == 'Hidden Gems'
assert get_registry().get('discovery_shuffle').display_name('') == 'Discovery Shuffle'
assert get_registry().get('popular_picks').display_name('') == 'Popular Picks'
# ─── generator dispatch ──────────────────────────────────────────────
class TestHiddenGemsGenerator:
def test_forwards_limit(self):
svc = _StubService()
_hg_mod.generate(_deps(svc), '', PlaylistConfig(limit=75))
assert svc.calls == [{'method': 'get_hidden_gems', 'limit': 75}]
def test_uses_default_limit_when_config_default(self):
svc = _StubService()
_hg_mod.generate(_deps(svc), '', PlaylistConfig())
assert svc.calls[0]['limit'] == 50
def test_coerces_tracks(self):
svc = _StubService(return_value=[
{'track_name': 'A', 'artist_name': 'X', 'spotify_track_id': 'sp-1'},
{'track_name': 'B', 'artist_name': 'Y', 'spotify_track_id': 'sp-2'},
])
out = _hg_mod.generate(_deps(svc), '', PlaylistConfig())
assert len(out) == 2
assert out[0].track_name == 'A'
assert out[0].spotify_track_id == 'sp-1'
def test_empty_service_output_returns_empty_list(self):
svc = _StubService(return_value=[])
out = _hg_mod.generate(_deps(svc), '', PlaylistConfig())
assert out == []
def test_none_service_output_returns_empty_list(self):
svc = _StubService(return_value=None)
out = _hg_mod.generate(_deps(svc), '', PlaylistConfig())
assert out == []
class TestDiscoveryShuffleGenerator:
def test_forwards_limit(self):
svc = _StubService()
_ds_mod.generate(_deps(svc), '', PlaylistConfig(limit=42))
assert svc.calls == [{'method': 'get_discovery_shuffle', 'limit': 42}]
def test_coerces_tracks(self):
svc = _StubService(return_value=[{'track_name': 'Z', 'artist_name': 'Q'}])
out = _ds_mod.generate(_deps(svc), '', PlaylistConfig())
assert out[0].track_name == 'Z'
class TestPopularPicksGenerator:
def test_forwards_limit(self):
svc = _StubService()
_pp_mod.generate(_deps(svc), '', PlaylistConfig(limit=10))
assert svc.calls == [{'method': 'get_popular_picks', 'limit': 10}]
# ─── deps validation ─────────────────────────────────────────────────
class TestDepsValidation:
def test_missing_service_raises(self):
# No `service` attribute on deps.
deps = SimpleNamespace()
with pytest.raises(RuntimeError, match='missing `service`'):
_hg_mod.generate(deps, '', PlaylistConfig())
def test_dict_form_deps_accepted(self):
# generators._common.get_service tolerates dict deps too.
svc = _StubService()
out = _hg_mod.generate({'service': svc}, '', PlaylistConfig())
assert isinstance(out, list)
assert svc.calls

@ -0,0 +1,131 @@
"""Boundary tests for variant-bearing personalized generators
(`time_machine` per decade, `genre_playlist` per genre).
Each generator coerces a URL-safe variant string into the form the
legacy service expects, then forwards. Tests pin the variant
parsing + service dispatch + variant_resolver listing."""
from __future__ import annotations
from types import SimpleNamespace
from typing import List
import pytest
from core.personalized.generators import genre_playlist as _gp_mod
from core.personalized.generators import time_machine as _tm_mod
from core.personalized.specs import get_registry
from core.personalized.types import PlaylistConfig
class _StubService:
GENRE_MAPPING = {
'Electronic/Dance': ['house', 'techno'],
'Hip Hop/Rap': ['hip hop', 'rap'],
'Rock': ['rock', 'punk'],
}
def __init__(self):
self.calls: List[dict] = []
def get_decade_playlist(self, decade, limit, **kw):
self.calls.append({'method': 'get_decade_playlist', 'decade': decade, 'limit': limit})
return [{'track_name': f'D{decade}', 'artist_name': 'A'}]
def get_genre_playlist(self, genre, limit, **kw):
self.calls.append({'method': 'get_genre_playlist', 'genre': genre, 'limit': limit})
return [{'track_name': f'G{genre}', 'artist_name': 'A'}]
def _deps():
return SimpleNamespace(service=_StubService())
# ─── time_machine ───────────────────────────────────────────────────
class TestTimeMachine:
def test_registered(self):
spec = get_registry().get('time_machine')
assert spec is not None
assert spec.requires_variant is True
assert spec.variant_resolver is not None
def test_variant_resolver_returns_decades(self):
spec = get_registry().get('time_machine')
decades = spec.variant_resolver(_deps())
assert '1980s' in decades
assert '2020s' in decades
# All decades should be 4-digit + 's'
for d in decades:
assert d.endswith('s')
assert d[:-1].isdigit()
def test_decade_label_to_year(self):
deps = _deps()
_tm_mod.generate(deps, '1980s', PlaylistConfig(limit=20))
assert deps.service.calls == [
{'method': 'get_decade_playlist', 'decade': 1980, 'limit': 20}
]
def test_invalid_variant_raises(self):
deps = _deps()
with pytest.raises(ValueError, match='not a decade label'):
_tm_mod.generate(deps, 'banana', PlaylistConfig())
def test_out_of_range_year_raises(self):
deps = _deps()
with pytest.raises(ValueError, match='out of range'):
_tm_mod.generate(deps, '1500s', PlaylistConfig())
def test_tolerates_no_s_suffix(self):
deps = _deps()
_tm_mod.generate(deps, '1990', PlaylistConfig())
assert deps.service.calls[0]['decade'] == 1990
def test_default_limit_is_100(self):
spec = get_registry().get('time_machine')
assert spec.default_config.limit == 100
def test_display_name_with_variant(self):
spec = get_registry().get('time_machine')
assert spec.display_name('1980s') == 'Time Machine — 1980s'
# ─── genre_playlist ─────────────────────────────────────────────────
class TestGenrePlaylist:
def test_registered(self):
spec = get_registry().get('genre_playlist')
assert spec is not None
assert spec.requires_variant is True
def test_variant_resolver_normalizes_parent_keys(self):
spec = get_registry().get('genre_playlist')
variants = spec.variant_resolver(_deps())
# 'Electronic/Dance' → 'electronic_dance' (slash → underscore + lowercase)
assert 'electronic_dance' in variants
assert 'hip_hop_rap' in variants
assert 'rock' in variants
def test_normalized_variant_resolves_to_parent_key(self):
deps = _deps()
_gp_mod.generate(deps, 'electronic_dance', PlaylistConfig())
# Service receives ORIGINAL parent key.
assert deps.service.calls[0]['genre'] == 'Electronic/Dance'
def test_unknown_variant_passed_through_as_freeform(self):
# Service handles partial-matching for free-form keywords.
deps = _deps()
_gp_mod.generate(deps, 'shoegaze', PlaylistConfig())
assert deps.service.calls[0]['genre'] == 'shoegaze'
def test_empty_variant_raises(self):
deps = _deps()
with pytest.raises(ValueError, match='requires a variant'):
_gp_mod.generate(deps, '', PlaylistConfig())
def test_display_name(self):
spec = get_registry().get('genre_playlist')
assert spec.display_name('electronic_dance') == 'Genre — electronic_dance'
Loading…
Cancel
Save