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