-
v2026.08.03 — Discover requests: taste-aware, rotating, snoozable, tag-targeted
Stabletest-web / test (push) Successful in 1m5stest-go / test (push) Successful in 1m30sandroid / Build + lint + test (push) Successful in 5m1stest-go / integration (push) Successful in 5m29srelease / Build signed APK (tag releases only) (push) Successful in 4m21srelease / Build + push container image (push) Successful in 17sreleased this
2026-08-03 08:46:50 -04:00 | 2 commits to main since this releasePer-day CalVer. Milestone #268 — the Discover request surface, rebuilt. Server + web + Android, with schema migrations. Merged via PR #116;
mainCI green on324059b2(Go, web, Android incl. debug assemble, and the image build).The Discover request surface was the last recommendation surface still running its early-May M5c implementation. Reported symptoms: "shows the same artists until you 'request' one" and "once it has a strong signal of your taste seems to go stale." Both were real, with separate causes — and the second got worse the more you listened, by construction.
Why it went stale
Four mechanisms, all fixed:
- No rotation. A deterministic top-N over a score ordering shows the same faces every day until one is requested away — the tail of the ranking was simply unreachable.
- Unbounded signal.
signal = 5×liked + Σexp(-age/halflife)grew without limit, so a few heavily-played artists monopolised every slot and entrenched harder as listening accumulated. - Skips counted as engagement. No
was_skippedfilter, so skipping an artist repeatedly increased its signal and pushed more of its neighbours at you. - No diversity cap. All twelve slots could be neighbours of one artist.
Seeded from your taste profile, not raw play counts
Seeds now come from
taste_profile_artists.weight— already decayed, damped and signed, so an artist you've drifted away from stops contributing instead of accumulating forever, and can contribute negatively. Log-damped so one heavy artist can't monopolise. Skips no longer read as affinity.While the profile is still empty (new account, or before the first nightly recompute) it falls back to likes + non-skipped plays, so the surface never goes blank.
The deck rotates daily
Top scorers always lead; the remaining slots rotate on
md5(mbid || date)— stable within a day so pull-to-refresh doesn't reshuffle under you, different tomorrow, and it needs no stored state. A per-seed cap stops one artist owning the deck, with a floor: diversity is a preference with score-order top-up, never a quota that leaves you with a three-card surface."Not right now" — a snooze, not a dislike
You can park a suggestion for ~90 days. It expires on its own, records no verdict on the music, and never touches your taste profile — this is acquisition triage, not negative feedback. That's enforced structurally, not by convention: the filter sits at the candidate stage rather than in the score, because a scoring term is exactly where it would leak.
Both clients flip the card in place with an Undo rather than yanking it out from under your finger, and keep a "Not right now" list below the deck with a return estimate — that list is the only route back once a card has left the deck, so it isn't optional garnish. On Android the write goes through the offline queue, modelled so a snooze queued while offline can't replay after you've undone it.
It can now tell you why
Cards say "Matches your taste in shoegaze and dream pop." when the artist's own tags overlap your taste profile — describing the music rather than the graph ("adjacent to something you played").
This needed a tag cache for artists you don't own (
track_tagscan't serve it — it's keyed to tracks you have). A background worker fills it strongest-candidates-first, because the candidate pool is far larger than MusicBrainz's ~1 req/s can ever fully cover. Expect this to be sparse at first and to fill in over days. Coverage will always be partial — obscure artists stay thin — so an untagged candidate is scored exactly as before rather than pushed down.New: Admin → Tuning → "Discover requests"
Two DB-backed knobs, no restart:
- Taste-tag weight — how strongly a tag match boosts a candidate.
score × (1 + w × overlap). Setting it to 0 turns the tag term off entirely and returns the deck to similarity-only ranking. - Snooze length — how long "not right now" parks something.
Its own scope rather than part of the taste card, deliberately: snooze length filed under "taste" would sit one careless join away from the leak described above.
Also fixed
- Generated database code was never verified against its SQL sources (#2380). 307 queries, ~12k lines of committed codegen, and nothing checked it still matched. CI now verifies it before anything else runs — and it has already caught two real drifts that the integration tests passed straight through. Passing tests against a real database proves the SQL is valid, not that the committed Go matches it.
- That check was blind to newly added files (#2382) —
git diffignores untracked paths, so a commit that added a query and forgot its generated file would have passed. This release contains the first commit that would have tripped it. - "in about a month" was unreachable in both clients (#2392) — a threshold sitting above its own divisor made the branch dead code. Found by the unit test written for it.
Install
- Redeploy the server image — this release carries server changes and migrations 0049, 0050, 0051, which apply automatically on restart. 0051 also widens the tuning-audit scope constraint (same migration, per policy).
- Install the attached signed APK — the Android client carries the snooze affordance and the tag attribution.
Web needs nothing beyond the server redeploy.
Verify
CI covered all three lanes on every commit. These are the parts it cannot sign off:
- Come back tomorrow. Does the deck actually change without you requesting anything? That's symptom one.
- Over the next weeks, does it stop narrowing as your listening concentrates? That's symptom two, and it's the one that used to get worse with use.
- Snooze a suggestion. The card should flip in place with an Undo, not vanish under your finger — then appear in "Not right now" below with a return estimate. Undo from either place.
- Tag attribution. Watch for "Matches your taste in …" appearing on more cards over the coming days as the enricher works through the backlog. Sparse at first is expected, not a fault.
- Admin → Tuning → Discover requests. Set taste-tag weight to 0, refresh Discover: the ordering should visibly revert to similarity-only. Reset to defaults to restore.
Known and deliberate
Tag coverage for out-of-library artists is permanently partial — it depends on the artist having an MBID and on MusicBrainz/Last.fm actually having tags for them. The ranking is built so that costs an untagged candidate nothing rather than penalising it, and the admin card's copy says so. Coverage counts are queryable server-side if you want to see the shape of it.
🤖 Generated with Claude Code
Downloads