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:
parent
9f383acbfb
commit
cc0828e9ff
2 changed files with 90 additions and 0 deletions
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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')])
|
||||
|
|
|
|||
Loading…
Reference in a new issue