Snapshots now track when their source data changes. Watchlist scan emits stale flags on the playlists whose underlying pool just got refreshed; the next pipeline run sees the flag and regenerates the snapshot before syncing, so the server playlist never lags the source. Schema: - new `is_stale INTEGER NOT NULL DEFAULT 0` column on `personalized_playlists`, plus an idempotent ADD COLUMN migration in `ensure_personalized_schema` for installs created before this PR. - `PlaylistRecord.is_stale: bool = False` exposed on the dataclass so callers can branch on freshness without re-querying. Manager: - new `mark_kinds_stale(kinds, profile_id=None)` flips the flag in bulk for a list of kinds (used by upstream data refreshers). - `_persist_snapshot` clears `is_stale = 0` on successful refresh. - SELECT statements + `_row_to_record` updated to read the column (with tuple-form length guard for safety). Pipeline: - `_build_payloads_for_kinds` now branches: refresh_first=True OR `existing.is_stale` -> refresh_playlist, else read existing snapshot. So the auto-refresh kicks in without needing the user to toggle the refresh-each-run option. Watchlist scanner emits stale flags at three sites: - after `update_discovery_pool_timestamp` -> marks pool-fed kinds stale: hidden_gems, discovery_shuffle, popular_picks, time_machine, genre_playlist, daily_mix. - after release_radar `save_curated_playlist` -> marks `fresh_tape`. - after discovery_weekly `save_curated_playlist` -> marks `archives`. All three calls go through a module-level `_mark_personalized_kinds_stale` helper that builds a PersonalizedPlaylistManager with `deps=None` (only DB access is needed for the flag update — no generator dispatch). Each call is wrapped in try/except so a flag failure can never abort the scan itself. Tests: - new `TestStaleFlag` class in `test_personalized_manager.py` (6 tests): default-false, single-kind flip, multi-kind, profile scoping, refresh-clears, empty-list noop. - two new pipeline tests pin the auto-refresh dispatch: `test_stale_snapshot_auto_refreshes_even_without_refresh_first` and `test_non_stale_snapshot_skips_refresh`. - existing stub-manager `SimpleNamespace` returns gained `is_stale=False` so the new attribute read doesn't AttributeError. Full suite: 3391 pass. User-facing WHATS_NEW entry added under 2.5.2 (above the prior pipeline auto-sync entry) describing the auto-refresh behavior.
159 lines
6 KiB
Python
159 lines
6 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,
|
|
is_stale INTEGER NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE (profile_id, kind, variant)
|
|
)
|
|
"""
|
|
|
|
# Migration for installs that created the table before is_stale existed.
|
|
PERSONALIZED_PLAYLISTS_STALE_MIGRATION = """
|
|
ALTER TABLE personalized_playlists ADD COLUMN is_stale INTEGER NOT NULL DEFAULT 0
|
|
"""
|
|
|
|
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)
|
|
|
|
# Add is_stale column on installs that created the table before
|
|
# this column existed. SQLite has no `ADD COLUMN IF NOT EXISTS` so
|
|
# we probe with PRAGMA + tolerate the OperationalError that fires
|
|
# when the column is already there.
|
|
cursor.execute("PRAGMA table_info(personalized_playlists)")
|
|
cols = {row[1] for row in cursor.fetchall()}
|
|
if 'is_stale' not in cols:
|
|
try:
|
|
cursor.execute(PERSONALIZED_PLAYLISTS_STALE_MIGRATION)
|
|
logger.info("Added is_stale column to personalized_playlists")
|
|
except Exception as e:
|
|
logger.debug("is_stale column migration: %s", e)
|
|
|
|
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',
|
|
]
|