soulsync/core/personalized/specs.py
Broque Thomas 79224ed294 Personalized playlists (1/N): unified storage + manager foundation
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.
2026-05-15 16:19:01 -07:00

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',
]