soulsync/database/personalized_schema.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

139 lines
5.1 KiB
Python

"""Schema + migration helpers for the personalized-playlists subsystem.
Pre-existing state (this PR replaces over time):
- Group A (Fresh Tape / The Archives / Seasonal Mix) lives in
`discovery_curated_playlists` (track_ids only) and
`curated_seasonal_playlists` (track_ids + seasonal_tracks join).
Read paths exist; refresh paths are tied to specific workers.
- Group B (Hidden Gems / Discovery Shuffle / Time Machine / Popular
Picks / Genre / Daily Mixes) is computed on-demand by
`PersonalizedPlaylistsService` — no persistence, every call reruns
the generator with `ORDER BY RANDOM()` so the result rotates.
Post-overhaul (this module's responsibility):
- ALL personalized playlists land in a unified storage layer with a
stable (profile_id, kind, variant) identity, JSON config per
playlist (limit, diversity caps, popularity / recency filters,
exclude-recent-days, randomization seed), and a persistent track
list that only mutates when the playlist is explicitly refreshed.
Tables created here:
- ``personalized_playlists`` — one row per (profile, kind, variant).
Variants disambiguate kinds with multiple instances:
* ``time_machine``: variant = ``'1980s'`` / ``'1990s'`` / ...
* ``seasonal_mix``: variant = ``'halloween'`` / ``'christmas'`` / ...
* ``genre_playlist``: variant = ``'rock'`` / ``'electronic_dance'`` / ...
* ``daily_mix``: variant = ``'1'`` / ``'2'`` / ``'3'`` / ``'4'``
* Singletons (``hidden_gems``, ``discovery_shuffle``,
``popular_picks``, ``fresh_tape``, ``archives``): variant = ``''``.
Variant '' (empty) is used instead of NULL so the UNIQUE
constraint behaves predictably (NULL doesn't collide with NULL in
SQLite UNIQUE indexes — would let multiple singleton rows
coexist).
- ``personalized_playlist_tracks`` — current snapshot per playlist.
Cleared + repopulated on refresh; never partial-mutates.
- ``personalized_track_history`` — append-only log of which tracks
were served by which (profile, kind) over time. Powers the
``exclude_recent_days`` config option so generators can avoid
recommending the same track twice in N days.
The schema is created idempotently — `ensure_personalized_schema`
runs CREATE TABLE IF NOT EXISTS at startup, so existing installs
upgrade silently."""
from __future__ import annotations
from typing import Any
from utils.logging_config import get_logger
logger = get_logger("database.personalized_schema")
PERSONALIZED_PLAYLISTS_DDL = """
CREATE TABLE IF NOT EXISTS personalized_playlists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL DEFAULT 1,
kind TEXT NOT NULL,
variant TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
track_count INTEGER NOT NULL DEFAULT 0,
last_generated_at TIMESTAMP,
last_synced_at TIMESTAMP,
last_generation_source TEXT,
last_generation_error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (profile_id, kind, variant)
)
"""
PERSONALIZED_PLAYLIST_TRACKS_DDL = """
CREATE TABLE IF NOT EXISTS personalized_playlist_tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER NOT NULL,
position INTEGER NOT NULL,
spotify_track_id TEXT,
itunes_track_id TEXT,
deezer_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,
FOREIGN KEY (playlist_id) REFERENCES personalized_playlists(id) ON DELETE CASCADE,
UNIQUE (playlist_id, position)
)
"""
PERSONALIZED_PLAYLIST_TRACKS_INDEX = """
CREATE INDEX IF NOT EXISTS idx_personalized_tracks_playlist
ON personalized_playlist_tracks(playlist_id)
"""
PERSONALIZED_TRACK_HISTORY_DDL = """
CREATE TABLE IF NOT EXISTS personalized_track_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL DEFAULT 1,
kind TEXT NOT NULL,
track_id TEXT NOT NULL,
served_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
PERSONALIZED_TRACK_HISTORY_INDEX = """
CREATE INDEX IF NOT EXISTS idx_personalized_history_lookup
ON personalized_track_history(profile_id, kind, track_id, served_at)
"""
def ensure_personalized_schema(connection: Any) -> None:
"""Create the personalized-playlist tables + indexes if missing.
Idempotent. Safe to call on every app startup. Caller is responsible
for committing the connection (we leave that to the caller so this
composes with other schema-init steps in one transaction).
"""
cursor = connection.cursor()
cursor.execute(PERSONALIZED_PLAYLISTS_DDL)
cursor.execute(PERSONALIZED_PLAYLIST_TRACKS_DDL)
cursor.execute(PERSONALIZED_PLAYLIST_TRACKS_INDEX)
cursor.execute(PERSONALIZED_TRACK_HISTORY_DDL)
cursor.execute(PERSONALIZED_TRACK_HISTORY_INDEX)
logger.debug("Personalized-playlist schema ensured")
__all__ = [
'ensure_personalized_schema',
'PERSONALIZED_PLAYLISTS_DDL',
'PERSONALIZED_PLAYLIST_TRACKS_DDL',
'PERSONALIZED_PLAYLIST_TRACKS_INDEX',
'PERSONALIZED_TRACK_HISTORY_DDL',
'PERSONALIZED_TRACK_HISTORY_INDEX',
]