From cc0828e9ff23546d1d15b8c64ed2e811deb2b065 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 15 May 2026 17:18:32 -0700 Subject: [PATCH] Personalized playlists (4/N): staleness post-filter (exclude_recent_days) Adds the first quality feature on top of the manager: when `config.exclude_recent_days > 0`, the manager drops any track from the generator's output whose primary id was served by this kind for this profile in the last N days. Lives at the manager layer, not in each generator, so: - generators stay focused on selection logic - staleness behavior stays uniform across every kind - enabling/disabling per playlist is just a config patch Implementation: - New `PersonalizedPlaylistManager._apply_quality_filters` runs after generator returns, before `_persist_snapshot`. - Reads recent ids via existing `recent_track_ids` accessor. - Tracks without a primary id pass through unchanged (nothing to dedupe on -- happens for sourceless tracks during edge cases). - Returns a new list (never mutates input). Default `exclude_recent_days = 0` preserves pre-overhaul behavior. Per-playlist override via `PUT /api/personalized/playlist//config` with `{"exclude_recent_days": N}`. Recommended values: - Discovery Shuffle: 1-3 days (high churn desired) - Hidden Gems: 7-14 days (avoid same gems weekly) - Time Machine / Genre: 30+ days (slow rotation OK, stable view preferred) 4 new boundary tests: - Zero days = no filter (default behavior preserved) - Positive days drops tracks served in window - Filter preserves new tracks alongside dropped ones - Tracks without primary id pass through unchanged 3369 tests pass total. Note: listening-history cross-ref + seeded shuffle are deferred to a future PR. Each requires deeper integration -- listening history needs a play-events table the discovery pool can query against; seeded shuffle needs the legacy generators to accept a seed param without breaking their existing diversity / popularity logic. --- core/personalized/manager.py | 35 +++++++++++++++++++ tests/test_personalized_manager.py | 55 ++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/core/personalized/manager.py b/core/personalized/manager.py index e171844f..49699627 100644 --- a/core/personalized/manager.py +++ b/core/personalized/manager.py @@ -174,8 +174,43 @@ class PersonalizedPlaylistManager: self._record_generation_failure(record.id, str(exc)) return self._fetch_playlist_row(kind, variant, profile_id) # type: ignore[return-value] + # Quality post-filters — applied uniformly to every kind so + # generators stay focused on selection logic, not staleness + # bookkeeping. Filters are config-driven; defaults preserve + # the pre-overhaul behavior (no filtering). + tracks = self._apply_quality_filters(tracks, kind, profile_id, config) + return self._persist_snapshot(record.id, kind, profile_id, tracks) + def _apply_quality_filters( + self, + tracks: List[Track], + kind: str, + profile_id: int, + config: PlaylistConfig, + ) -> List[Track]: + """Apply manager-level quality filters to a generator's output. + + Currently: + - **Staleness window** (`config.exclude_recent_days > 0`): drops + any track whose primary id was served by this `kind` for this + `profile_id` in the last N days. Prevents the same track + from showing up across consecutive refreshes — e.g. a daily + Discovery Shuffle that shouldn't replay yesterday's picks. + Tracks without a primary id pass through unchanged (nothing + to dedupe on). + + Returns a new list (never mutates input). When no filter + applies, returns ``tracks`` unchanged.""" + if config.exclude_recent_days <= 0 or not tracks: + return tracks + + recent_set = set(self.recent_track_ids(profile_id, kind, config.exclude_recent_days)) + if not recent_set: + return tracks + + return [t for t in tracks if not t.primary_id() or t.primary_id() not in recent_set] + # ─── track read ────────────────────────────────────────────────── def get_playlist_tracks(self, playlist_id: int) -> List[Track]: diff --git a/tests/test_personalized_manager.py b/tests/test_personalized_manager.py index f793442f..3ab5de17 100644 --- a/tests/test_personalized_manager.py +++ b/tests/test_personalized_manager.py @@ -394,6 +394,61 @@ class TestListPlaylists: assert len(mgr.list_playlists(2)) == 1 +class TestStalenessFilter: + """`config.exclude_recent_days > 0` drops tracks served by this + kind for this profile in the last N days.""" + + def test_zero_days_means_no_filter(self, db, registry): + # Default config has exclude_recent_days=0; everything passes. + tracks = [_make_track(sid='spot-1'), _make_track(sid='spot-2')] + run = {'tracks': tracks} + _register_simple_kind(registry, lambda *a, **k: run['tracks']) + mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry) + r1 = mgr.refresh_playlist('hidden_gems', '', 1) + # Refresh again with same tracks — no filter, all should persist. + r2 = mgr.refresh_playlist('hidden_gems', '', 1) + assert r2.track_count == 2 + + def test_positive_days_filters_recently_served(self, db, registry): + run = {'tracks': [_make_track(sid='spot-1'), _make_track(sid='spot-2')]} + _register_simple_kind(registry, lambda *a, **k: run['tracks']) + mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry) + r1 = mgr.refresh_playlist('hidden_gems', '', 1) + assert r1.track_count == 2 + # Update config to exclude tracks served in last 7 days. + mgr.update_config('hidden_gems', '', 1, {'exclude_recent_days': 7}) + # Same generator output now → all tracks just got served, all filtered out. + r2 = mgr.refresh_playlist('hidden_gems', '', 1) + assert r2.track_count == 0 + + def test_filter_preserves_non_recent_tracks(self, db, registry): + run = {'tracks': [_make_track(sid='spot-1')]} + _register_simple_kind(registry, lambda *a, **k: run['tracks']) + mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry) + r1 = mgr.refresh_playlist('hidden_gems', '', 1) + mgr.update_config('hidden_gems', '', 1, {'exclude_recent_days': 7}) + # New generator output with a NEW id — should pass. + run['tracks'] = [_make_track(sid='spot-1'), _make_track(sid='spot-NEW')] + r2 = mgr.refresh_playlist('hidden_gems', '', 1) + # spot-1 was just served, dropped. spot-NEW is fresh, kept. + assert r2.track_count == 1 + persisted = mgr.get_playlist_tracks(r2.id) + assert persisted[0].spotify_track_id == 'spot-NEW' + + def test_tracks_without_primary_id_pass_through(self, db, registry): + # Track with no source IDs — primary_id() is None — staleness + # filter has nothing to dedupe on, so the track passes. + track_no_id = Track(track_name='X', artist_name='Y', source='spotify') + run = {'tracks': [track_no_id]} + _register_simple_kind(registry, lambda *a, **k: run['tracks']) + mgr = PersonalizedPlaylistManager(db, deps=None, registry=registry) + mgr.refresh_playlist('hidden_gems', '', 1) + mgr.update_config('hidden_gems', '', 1, {'exclude_recent_days': 7}) + r2 = mgr.refresh_playlist('hidden_gems', '', 1) + # Track is kept because there's no id to match against history. + assert r2.track_count == 1 + + class TestStalenessHistory: def test_recent_track_ids_returns_zero_when_days_zero(self, db, registry): _register_simple_kind(registry, lambda *a, **k: [_make_track(sid='spot-1')])