mirror of https://github.com/Nezreka/SoulSync.git
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
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)
|
||||
@ -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…
Reference in new issue