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/<kind>/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.
This commit is contained in:
Broque Thomas 2026-05-15 17:18:32 -07:00
parent 9f383acbfb
commit cc0828e9ff
2 changed files with 90 additions and 0 deletions

View file

@ -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]:

View file

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