Begins the standardization of the personalized-playlist subsystem.
Pre-existing state was a patchwork: Group A (Fresh Tape / Archives /
Seasonal Mix) lived in `discovery_curated_playlists` and
`curated_seasonal_playlists` with inconsistent shapes; Group B
(Hidden Gems / Discovery Shuffle / Time Machine / Popular Picks /
Genre / Daily Mixes) was computed on-demand by
`PersonalizedPlaylistsService` with no persistence -- every call
reran the generator with `ORDER BY RANDOM()` so results rotated.
Post-overhaul (this PR) every personalized playlist lands in one
unified storage layer with stable identity, persistent track lists,
explicit refresh, and per-playlist user-tweakable config.
Foundation in this commit (no behavior change yet):
- `database/personalized_schema.py`: 3 tables created idempotently
at app startup (wired into `MusicDatabase._initialize_database`).
- `personalized_playlists`: one row per (profile, kind, variant)
with config_json, track_count, last_generated_at,
last_synced_at, last_generation_source, last_generation_error.
Variant '' (empty string) for singletons; non-empty for
time_machine / seasonal_mix / genre_playlist / daily_mix.
- `personalized_playlist_tracks`: current snapshot per playlist.
Atomically replaced on refresh.
- `personalized_track_history`: append-only log powering the
`exclude_recent_days` config knob.
- `core/personalized/types.py`: `Track`, `PlaylistConfig`,
`PlaylistRecord` dataclasses. `PlaylistConfig.merged()` for
partial-update PATCH semantics; `Track.from_dict()` accepts the
legacy generator output shape unchanged.
- `core/personalized/specs.py`: `PlaylistKindSpec` (kind,
name_template, default_config, generator, variant_resolver) and a
module-level registry. Generators register at import time;
manager dispatches by kind.
- `core/personalized/manager.py`: `PersonalizedPlaylistManager` --
the only thing that touches the new tables. Owns:
- ensure_playlist (auto-create row from kind defaults)
- get_playlist / list_playlists
- refresh_playlist (atomic snapshot replace; generator exception
preserves previous good snapshot + records error on row)
- get_playlist_tracks
- update_config (deep-merge with stored config, including extra dict)
- recent_track_ids (staleness lookup for generators)
35 boundary tests in `tests/test_personalized_manager.py` pin every
shape: config round-trip / merge semantics / extra deep-merge /
defaults; Track.from_dict tolerance + primary_id fallback chain;
registry dedup / display_name with+without variant; manager
ensure_playlist auto-create + idempotency, variant separation,
required-variant enforcement, unknown-kind error; refresh persists
+ replaces atomically + survives generator exception with previous
snapshot intact + records source from first track + round-trips
nested track_data_json; update_config patch semantics; list_playlists
profile scoping; staleness history scoped to (profile, kind, days).
3304 tests pass total. Generators ship in subsequent commits on this
branch -- each kind migrated one at a time with its own per-kind
boundary tests.
121 lines
4 KiB
Python
121 lines
4 KiB
Python
"""Per-kind specifications for the personalized-playlist subsystem.
|
|
|
|
A ``PlaylistKindSpec`` declares everything the manager needs to know
|
|
about one playlist type:
|
|
|
|
- The kind identifier (stable string used in URLs / configs / DB).
|
|
- Human-readable display name template (with optional ``{variant}``
|
|
substitution).
|
|
- Whether the kind supports / requires variants and what valid
|
|
variants look like.
|
|
- The default user-tweakable config for this kind.
|
|
- A generator callable that produces a fresh track list given
|
|
``(deps, variant, config)``.
|
|
|
|
Generators live in ``core/personalized/generators/`` (added in
|
|
later commits as each kind is migrated). For commit 1 the registry
|
|
ships empty — schema + manager land first; generators arrive
|
|
incrementally with their per-kind tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Callable, Dict, List, Optional
|
|
|
|
from core.personalized.types import PlaylistConfig, Track
|
|
|
|
|
|
# Generator callable signature.
|
|
# Args:
|
|
# deps: opaque object the generator may need (DB, service handle,
|
|
# config_manager). Each generator declares what it pulls.
|
|
# variant: e.g. '1980s' for time machine, 'halloween' for seasonal.
|
|
# Empty string '' for singleton kinds.
|
|
# config: PlaylistConfig with the user's per-playlist overrides
|
|
# merged onto the kind's defaults.
|
|
# Returns:
|
|
# List[Track] — the fresh snapshot to persist as the playlist's
|
|
# current track list.
|
|
GeneratorFn = Callable[[Any, str, PlaylistConfig], List[Track]]
|
|
|
|
|
|
# Variant resolver: returns the list of currently-valid variant
|
|
# identifiers for a kind that supports multiple instances. Used by
|
|
# the manager when auto-creating playlist rows for newly available
|
|
# variants (e.g. a new decade, a new season). Singletons return [''].
|
|
VariantResolver = Callable[[Any], List[str]]
|
|
|
|
|
|
@dataclass
|
|
class PlaylistKindSpec:
|
|
"""Declaration of one playlist kind.
|
|
|
|
See module docstring for the contract.
|
|
"""
|
|
|
|
kind: str
|
|
name_template: str # e.g. 'Time Machine — {variant}', 'Hidden Gems'
|
|
description: str
|
|
default_config: PlaylistConfig
|
|
generator: GeneratorFn
|
|
variant_resolver: Optional[VariantResolver] = None
|
|
requires_variant: bool = False
|
|
# Tags for UI grouping ('curated' / 'discovery' / 'time' / 'genre').
|
|
tags: List[str] = field(default_factory=list)
|
|
|
|
def display_name(self, variant: str) -> str:
|
|
"""Render the human-readable playlist name for a given variant."""
|
|
if not variant:
|
|
return self.name_template.replace('{variant}', '').strip(' —-')
|
|
return self.name_template.format(variant=variant)
|
|
|
|
|
|
class PlaylistKindRegistry:
|
|
"""Module-level registry of every kind the manager knows about.
|
|
|
|
Populated at import time as each generator module is loaded. The
|
|
manager queries the registry at runtime to dispatch refresh
|
|
requests, list available kinds for the UI, and resolve variants.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._kinds: Dict[str, PlaylistKindSpec] = {}
|
|
|
|
def register(self, spec: PlaylistKindSpec) -> None:
|
|
if spec.kind in self._kinds:
|
|
raise ValueError(f"Kind {spec.kind!r} already registered")
|
|
self._kinds[spec.kind] = spec
|
|
|
|
def get(self, kind: str) -> Optional[PlaylistKindSpec]:
|
|
return self._kinds.get(kind)
|
|
|
|
def all(self) -> List[PlaylistKindSpec]:
|
|
return list(self._kinds.values())
|
|
|
|
def kinds(self) -> List[str]:
|
|
return list(self._kinds.keys())
|
|
|
|
def reset_for_tests(self) -> None:
|
|
"""Drop every registration. Tests only — production runs
|
|
register at module import and never reset."""
|
|
self._kinds.clear()
|
|
|
|
|
|
# Module-level singleton. Generators register against this on import.
|
|
_registry = PlaylistKindRegistry()
|
|
|
|
|
|
def get_registry() -> PlaylistKindRegistry:
|
|
"""Public accessor for the module-level registry. Tests can reset
|
|
via ``get_registry().reset_for_tests()``."""
|
|
return _registry
|
|
|
|
|
|
__all__ = [
|
|
'PlaylistKindSpec',
|
|
'PlaylistKindRegistry',
|
|
'GeneratorFn',
|
|
'VariantResolver',
|
|
'get_registry',
|
|
]
|