diff --git a/core/personalized/generators/__init__.py b/core/personalized/generators/__init__.py new file mode 100644 index 00000000..8bd6309a --- /dev/null +++ b/core/personalized/generators/__init__.py @@ -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 diff --git a/core/personalized/generators/_common.py b/core/personalized/generators/_common.py new file mode 100644 index 00000000..1b5d610f --- /dev/null +++ b/core/personalized/generators/_common.py @@ -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)] diff --git a/core/personalized/generators/archives.py b/core/personalized/generators/archives.py new file mode 100644 index 00000000..9da9a2f3 --- /dev/null +++ b/core/personalized/generators/archives.py @@ -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_`` +(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) diff --git a/core/personalized/generators/daily_mix.py b/core/personalized/generators/daily_mix.py new file mode 100644 index 00000000..c3c96e57 --- /dev/null +++ b/core/personalized/generators/daily_mix.py @@ -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) diff --git a/core/personalized/generators/discovery_shuffle.py b/core/personalized/generators/discovery_shuffle.py new file mode 100644 index 00000000..daf13509 --- /dev/null +++ b/core/personalized/generators/discovery_shuffle.py @@ -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) diff --git a/core/personalized/generators/fresh_tape.py b/core/personalized/generators/fresh_tape.py new file mode 100644 index 00000000..c179bf6d --- /dev/null +++ b/core/personalized/generators/fresh_tape.py @@ -0,0 +1,119 @@ +"""Fresh Tape (Spotify Release Radar) generator. + +Reads the curated track-id list cached in ``discovery_curated_playlists`` +under ``release_radar_`` (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) diff --git a/core/personalized/generators/genre_playlist.py b/core/personalized/generators/genre_playlist.py new file mode 100644 index 00000000..d74b1f94 --- /dev/null +++ b/core/personalized/generators/genre_playlist.py @@ -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) diff --git a/core/personalized/generators/hidden_gems.py b/core/personalized/generators/hidden_gems.py new file mode 100644 index 00000000..5a5f79de --- /dev/null +++ b/core/personalized/generators/hidden_gems.py @@ -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) diff --git a/core/personalized/generators/popular_picks.py b/core/personalized/generators/popular_picks.py new file mode 100644 index 00000000..c95fa16f --- /dev/null +++ b/core/personalized/generators/popular_picks.py @@ -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) diff --git a/core/personalized/generators/seasonal_mix.py b/core/personalized/generators/seasonal_mix.py new file mode 100644 index 00000000..6062fe46 --- /dev/null +++ b/core/personalized/generators/seasonal_mix.py @@ -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) diff --git a/core/personalized/generators/time_machine.py b/core/personalized/generators/time_machine.py new file mode 100644 index 00000000..b139be7f --- /dev/null +++ b/core/personalized/generators/time_machine.py @@ -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) diff --git a/core/personalized_playlists.py b/core/personalized_playlists.py index 64882b97..602520ab 100644 --- a/core/personalized_playlists.py +++ b/core/personalized_playlists.py @@ -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""" diff --git a/tests/test_personalized_generators_curated.py b/tests/test_personalized_generators_curated.py new file mode 100644 index 00000000..f8df2c33 --- /dev/null +++ b/tests/test_personalized_generators_curated.py @@ -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()) diff --git a/tests/test_personalized_generators_singletons.py b/tests/test_personalized_generators_singletons.py new file mode 100644 index 00000000..6f39fd5a --- /dev/null +++ b/tests/test_personalized_generators_singletons.py @@ -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 diff --git a/tests/test_personalized_generators_variants.py b/tests/test_personalized_generators_variants.py new file mode 100644 index 00000000..40eb04d6 --- /dev/null +++ b/tests/test_personalized_generators_variants.py @@ -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'