From 49783218d877bfe79c1e7189045c11e4ef4c8690 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sat, 4 Jul 2026 16:41:43 +0800 Subject: [PATCH] ingest: rotate recently-played tracks oldest-first when anti-repeat is exhausted When every candidate is within the anti-repeat window, the fallback replayed at random, ignoring how long ago each was heard. With a small feed (or a window larger than a source's pool) this is the *normal* path, and random picking clusters the same tracks together. Play the least-recently-heard candidate instead, so tracks rotate at the widest spacing the pool allows. - db: add last_played_at(keys) -> key -> most-recent play timestamp. - providers/listenbrainz: sort the exhausted pool oldest-first. - scheduler: on exhaustion, return the oldest-played of the drawn candidates rather than the last one drawn. Co-Authored-By: Claude Opus 4.8 --- ingest/radieo/db.py | 20 ++++++++++++++++++++ ingest/radieo/providers/listenbrainz.py | 12 ++++++++++-- ingest/radieo/scheduler.py | 12 ++++++++---- 3 files changed, 38 insertions(+), 6 deletions(-) diff --git a/ingest/radieo/db.py b/ingest/radieo/db.py index 6d40dfb..c20cda1 100644 --- a/ingest/radieo/db.py +++ b/ingest/radieo/db.py @@ -79,6 +79,26 @@ class Database: ).fetchall() return {r["track_key"] for r in rows} + def last_played_at(self, keys: set[str]) -> dict[str, float]: + """Map each of ``keys`` to the timestamp of its most recent play. + + Keys never played are absent from the result (treat as played "at 0", + i.e. longest ago). Used by the anti-repeat fallback to play the + least-recently-heard candidate instead of a random one when every + candidate is within the recent window. + """ + if not keys: + return {} + placeholders = ",".join("?" * len(keys)) + with self._lock: + rows = self._conn.execute( + "SELECT track_key, MAX(played_at) AS last" + f" FROM history WHERE track_key IN ({placeholders})" + " GROUP BY track_key", + tuple(keys), + ).fetchall() + return {r["track_key"]: r["last"] for r in rows} + def recent_locators(self, limit: int) -> set[str]: """Raw backend locators recently played (providers' cheap local filter).""" with self._lock: diff --git a/ingest/radieo/providers/listenbrainz.py b/ingest/radieo/providers/listenbrainz.py index 0717cab..59731d0 100644 --- a/ingest/radieo/providers/listenbrainz.py +++ b/ingest/radieo/providers/listenbrainz.py @@ -103,8 +103,16 @@ class ListenBrainzProvider: if not recs: return None recent = self._db.recent_keys(config.ANTIREPEAT_WINDOW) - pool = [r for r in recs if f"mbid:{r['mbid']}" not in recent] or list(recs) - random.shuffle(pool) + fresh = [r for r in recs if f"mbid:{r['mbid']}" not in recent] + if fresh: + random.shuffle(fresh) # genuinely unheard recently: order is free + pool = fresh + else: + # Every rec is within the recent window (small feed / large window): + # don't replay at random, march through them least-recently-heard + # first so each recurs at the widest spacing the feed allows. + last = self._db.last_played_at({f"mbid:{r['mbid']}" for r in recs}) + pool = sorted(recs, key=lambda r: last.get(f"mbid:{r['mbid']}", 0.0)) for rec in pool: track = self._resolve(rec) if track is not None: diff --git a/ingest/radieo/scheduler.py b/ingest/radieo/scheduler.py index a3d2c17..c457a2e 100644 --- a/ingest/radieo/scheduler.py +++ b/ingest/radieo/scheduler.py @@ -34,7 +34,7 @@ class Scheduler: if not self._entries: return None recent = self._db.recent_keys(config.ANTIREPEAT_WINDOW) - last = None + drawn = [] for _ in range(config.SCHEDULER_MAX_TRIES): track = self._pick() if track is None: @@ -42,10 +42,14 @@ class Scheduler: track = self._canonicalizer.canonicalize(track) if track.key not in recent: return track - last = track # recently played; try another + drawn.append(track) # recently played; try another log.debug("skipping recent %s", track) - # Everything drawn was recent (e.g. tiny library): play the last anyway. - return last + # Every draw was recent (e.g. tiny library): don't just replay the last + # one drawn — play whichever of them we've gone longest without hearing. + if not drawn: + return None + last = self._db.last_played_at({t.key for t in drawn}) + return min(drawn, key=lambda t: last.get(t.key, 0.0)) def _pick(self): """Weighted provider draw, falling through to the others when empty."""