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))
|
self._record_generation_failure(record.id, str(exc))
|
||||||
return self._fetch_playlist_row(kind, variant, profile_id) # type: ignore[return-value]
|
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)
|
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 ──────────────────────────────────────────────────
|
# ─── track read ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_playlist_tracks(self, playlist_id: int) -> List[Track]:
|
def get_playlist_tracks(self, playlist_id: int) -> List[Track]:
|
||||||
|
|
|
||||||
|
|
@ -394,6 +394,61 @@ class TestListPlaylists:
|
||||||
assert len(mgr.list_playlists(2)) == 1
|
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:
|
class TestStalenessHistory:
|
||||||
def test_recent_track_ids_returns_zero_when_days_zero(self, db, registry):
|
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')])
|
_register_simple_kind(registry, lambda *a, **k: [_make_track(sid='spot-1')])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue