Compare commits

..
7 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5 86af79bd2f feat(discover): time-boxed suggestion snooze, server side — #2374
test-go / test (push) Successful in 1m20s
test-go / integration (push) Successful in 5m1s
Migration 0049 adds suggestion_snoozes(user_id, candidate_mbid,
candidate_name, snoozed_until), and SuggestArtistsForUser excludes rows
whose snooze hasn't expired.

This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down
UI; a snooze is the approved shape instead because it records no verdict
on the music, expires on its own (~90d), and never reaches the taste
profile. It's acquisition triage — "not right now" — so the filter sits
at the candidate stage rather than in the score, where it would become a
ranking signal by the back door.

Per-user throughout (rule #47): one household member parking a candidate
leaves everyone else's deck untouched.

candidate_name is denormalized because suggestions are out-of-library by
definition — there is no artists row to resolve a display name from, and
the un-snooze list has to show something. That list is why GET
/discover/snoozes exists at all: a parked candidate is by definition
absent from the deck, so without it the DELETE would be unreachable.

Also fixes a hole in the codegen check from #2380: `git diff` ignores
untracked paths, so a brand-new generated file would have passed it
silently. `git add -N` first. This commit is the first to add one.

Endpoints:
  POST   /api/discover/suggestions/{mbid}/snooze  (body: name, days)
  DELETE /api/discover/suggestions/{mbid}/snooze
  GET    /api/discover/snoozes

UI lands in slice 4 (#2375) before any of this merges — rule #27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:51:51 -04:00
bvandeusenandClaude Opus 5 e006de5d4b fix(db): apply sqlc's actual output for SuggestArtistsForUser — #2380
test-go / test (push) Successful in 1m2s
test-go / integration (push) Successful in 4m55s
The new codegen check failed on its first run, against the slice-1 hand-edit,
which is precisely why it landed on its own commit.

What I got wrong: sqlc does not embed the leading `--` header block in the SQL
const. It strips those lines and promotes them to the generated method's Go doc
comment, gofmt-formatted — blank `//` separators around the indented list, tabs
for the indent. My hand-edit left the header inside the string AND left the
stale M5c doc comment sitting on the function, so the generated file described
behaviour the query no longer had.

Comments *inside* the statement body are kept as-is; only the header block moves.
Worth knowing before slices 5 and 6 add more queries.

Taken verbatim from the diff the check printed, which is the reason it prints
before asserting. Round-trip cost: one CI run, no guessing.

Note the integration lane passed on the previous push even with the wrong
generated file — the SQL text was valid and the signature was unchanged, so
executing it against real Postgres proved nothing about whether the committed
Go matched its source. That gap is exactly what #2380 closes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:23:35 -04:00
bvandeusenandClaude Opus 5 94e2cac03b ci(go): verify committed sqlc output matches its .sql sources — #2380
test-go / test (push) Failing after 43s
test-go / integration (push) Successful in 4m55s
internal/db/dbq is 39 files and ~12k lines of generated Go covering 307
queries, and nothing checked that it still matched internal/db/queries.
test-go.yml referenced sqlc.yaml only as a path trigger; sqlc never ran. So a
hand-edit, a half-applied regen, or a migration changed without a regen would
all pass CI while the typed layer quietly lied about the SQL underneath it —
which is the single thing adopting sqlc is supposed to buy.

This session's slice-1 change is an instance: its SQL const was verified
byte-identical against its own .sql source by script, but never against what
sqlc would actually emit. Nothing in the repo could have told the difference.

make verify-generate runs ahead of vet/lint/test, because if the typed layer
disagrees with its sources then everything downstream is testing a lie.

generate-go runs sqlc as a Go tool rather than a container: the ci-go image
already has Go, so this avoids docker-in-docker on the runner. It's pinned to
the same SQLC_VERSION as the existing containerised `generate`, so both routes
emit identical output and there is one version to bump — now annotated for
Renovate per rule #44.

The diff prints BEFORE the exit-code check on purpose. On failure the log then
holds sqlc's exact expected output, so correcting it is a copy rather than a
guess. That is also what makes new queries workable without installing
anything: this workstation has neither Go nor sqlc.

Makefile joins the workflow's paths:. Without it a Makefile-only change —
including this one — would not trigger the workflow that now depends on it.
Same class as #2204, where CI never ran on plugin/** changes.

Landing this on its own, ahead of slice 3, so that if it fails it is
unambiguous whether the drift came from slice 1 or from new code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:16:45 -04:00
bvandeusenandClaude Opus 5 b27029f674 feat(discover): rotate the suggestion deck daily + cap one seed's share — #2373
test-go / test (push) Successful in 28s
test-go / integration (push) Successful in 4m54s
Second half of the reported symptom: suggestions "show the same artists until
you request one". The ranking was `ORDER BY total_score DESC` with no
randomization and no seen-state, so the only things that could ever change the
deck were a candidate entering the library or the user filing a request. The
tail of the ranking was unreachable — requesting was literally the only lever.

No SQL change was needed. The query already takes a limit, so it over-fetches a
pool (4x the slots, capped at 60) and the selection moves to Go, where it is a
pure function of (pool, limit, day) — no DB, no clock — and therefore unit
testable in the fast lane instead of behind the integration gate.

Three rules. The best few by score always lead, so the strongest matches never
rotate out of sight (For You's head/tail shape). The remaining slots are drawn
by md5(mbid + day), the same daily-stable idiom the Home rows already use:
stable within a day so pull-to-refresh doesn't reshuffle, different tomorrow,
and no stored state. And a per-seed cap keeps roughly a quarter of the deck
attributable to any one seed artist, so twelve neighbours of a single artist
can't be the whole surface.

The cap is a preference, not a quota. A user whose pool hangs off one or two
seeds would otherwise get a three-card surface — worse than the monoculture
being avoided, and exactly the vanish-or-nothing shape rule #131 exists to
prevent — so a short deck tops up in score order from what the cap set aside.
This is also what keeps the existing Top12Cap integration test honest: its
30 candidates share one seed, and without the top-up it would return 3.

Eight unit tests, including one that had to be rewritten mid-change: the first
version asserted the cap against an evenly-spread pool, where the top-N is
already diverse and the assertion could not fail. It now uses a skewed pool
where one seed owns the entire top of the ranking, which is the only shape that
actually exercises a cap.

Dropped two //nolint:gosec directives added in passing — gosec isn't in
.golangci.yml, so they suppressed nothing and only implied a check that runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:56:42 -04:00
bvandeusenandClaude Opus 5 14aa22198f feat(discover): seed request suggestions from the taste profile — #2372
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m55s
The Discover request surface was the one recommendation surface still on its
M5c implementation from early May. #796's taste profile, #1488's taste_unheard
bucket and #1490's folksonomy enrichment all modernized in-library surfaces;
this one was never in scope for any of them, so it still projected raw likes +
plays through artist_similarity_unmatched.

Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed
over every play of the artist.

It is unbounded, and contribution is signal × similarity — so a handful of
heavily-played artists monopolize all twelve slots, and their share GROWS the
more the user listens. The surface entrenched harder the better it knew you,
which is exactly backwards and matches the reported "goes stale once it has a
strong signal of your taste".

It also counted every play_event with no was_skipped filter, so skipping an
artist repeatedly INCREASED its signal and pushed more of its neighbours at the
user. ListMostPlayedTracksForUser and the taste engine both filter skips; this
query was the odd one out.

Seeds now come from taste_profile_artists.weight, which the taste engine has
already engagement-graded, time-decayed and signed — an artist the user drifted
away from stops contributing instead of accumulating forever, and can even
contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is
the profile, tier 2 is likes + completed plays for a user who has no profile
rows yet (new account, or before the first daily recompute), so the surface
never empties. The old unfiltered-play signal is gone, not kept behind a toggle.

The signal is also log-damped, so one artist cannot take every slot even when
its weight dwarfs the rest.

$2 stays wired to the tier-2 decay: it is genuinely still used there, and
dropping the parameter would have changed the generated signature.

sqlc's image is not on this workstation and the change preserves the query
signature exactly — same three params, same seven columns — so only the
embedded SQL const moves. Both copies are edited and verified byte-identical
rather than pulling a container onto the operator's machine; a malformed query
fails the integration lane loudly, which is the real check either way.

Four integration tests cover what changed: a taste weight alone seeds with no
like or play; tier 2 does not run alongside tier 1; a non-positive weight never
seeds (guarded by a second positive row, so an empty tier 1 can't make it pass
for the wrong reason); and skip-only history seeds nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:45:34 -04:00
bvandeusenandClaude Opus 5 cf7b489fec fix(playlists): make the playlist-track replace atomic
android / Build + lint + test (push) Successful in 4m4s
`refreshDetail` did an un-transacted `deleteByPlaylist` + `upsertAll` — the
same shape as the Home index write that #2327 just fixed. Room's
InvalidationTracker fires after the DELETE, so an observer of
`observeByPlaylist` would see `emptyList()` before the new rows land, which is
exactly what made every Home row visibly collapse to empty and refill.

Nothing consumes `observeByPlaylist` today, so this is not a live defect — it's
a landmine. Making playlist detail cache-first later would have silently
reintroduced the flicker, and the reason would have been three layers away from
the symptom. One `@Transaction` now costs nothing and removes that.

`deleteByPlaylist` is left in place as the building block but is no longer
called from outside the DAO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:04:22 -04:00
bvandeusenandClaude Opus 5 8483948f23 docs(ci): true up ci-requirements.md — ci-android replaced ci-flutter
The sheet still described the pre-M8 world: "two CI images: ci-go +
ci-flutter", a ci-flutter dep list, and cross-workflow release polling
against flutter.yml. None of that is true now — flutter.yml is gone,
android.yml and release.yml both pull ci-android:36, and image-release
gates on `needs: [android-release]` instead of polling.

Family rule 39 makes this sheet CI-Runner's decision input for "add a dep
to an image vs. fork a variant", so a stale sheet quietly misinforms that
call: CI-Runner was still carrying ci-flutter for a consumer that no
longer exists, and had no record of ci-android's real consumer.

- Runtime images: ci-flutter:3.44 -> ci-android:36, with a note on why
  ci-flutter is now unconsumed and what would have to change to revive it.
- Image deps: replace the Flutter/Dart/NDK list with the actual
  ci-android surface (JDK 25 + Gradle 9.1 floor, SDK/build-tools 36, no
  NDK, ktlint + detekt).
- Label/image split: record that Android jobs still schedule on the
  flutter-ci label on purpose — it's a scheduling handle, not a toolchain
  assertion.
- Update channel: `needs:` gating, plus the non-tag rebundle path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 10:38:50 -04:00
19 changed files with 1264 additions and 50 deletions
+4
View File
@@ -27,6 +27,7 @@ on:
- 'go.mod' - 'go.mod'
- 'go.sum' - 'go.sum'
- 'sqlc.yaml' - 'sqlc.yaml'
- 'Makefile'
- 'internal/**' - 'internal/**'
- 'cmd/**' - 'cmd/**'
- '.golangci.yml' - '.golangci.yml'
@@ -53,6 +54,9 @@ jobs:
go version go version
golangci-lint --version golangci-lint --version
- name: Generated code matches queries (sqlc)
run: make verify-generate
- name: go vet - name: go vet
run: go vet ./... run: go vet ./...
+25 -1
View File
@@ -1,10 +1,34 @@
.PHONY: generate test test-short test-integration lint build .PHONY: generate generate-go verify-generate test test-short test-integration lint build
# renovate: datasource=docker depName=sqlc/sqlc
SQLC_VERSION := 1.31.1 SQLC_VERSION := 1.31.1
# Local codegen. Containerised so a dev needs no sqlc install.
generate: generate:
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate
# Same codegen, run as a Go tool instead of a container. This is the CI path:
# the ci-go image already has Go, so it avoids docker-in-docker. Pinned to the
# SAME version as `generate` above so both routes emit identical output.
generate-go:
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate
# Fail if the committed generated code no longer matches the .sql sources.
#
# Nothing verified this before, so internal/db/dbq could silently drift from
# internal/db/queries — a hand-edit, a half-applied regen, or a schema change
# without a regen would all pass CI while the typed layer lied about the SQL.
#
# The diff is printed BEFORE the exit-code check on purpose: when this fails,
# the log then contains sqlc's exact expected output, which is what you commit.
verify-generate: generate-go
# -N (intent-to-add) so a BRAND-NEW generated file is visible to `git
# diff`, which otherwise ignores untracked paths entirely — a whole
# missing *.sql.go would sail through the check below.
git add -N -- internal/db/dbq
git --no-pager diff -- internal/db/dbq
git diff --quiet -- internal/db/dbq
test: test:
go test -race ./... go test -race ./...
@@ -4,6 +4,7 @@ import androidx.room.Dao
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Transaction
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao {
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId") @Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
suspend fun maxPosition(playlistId: String): Int? suspend fun maxPosition(playlistId: String): Int?
/** Replace-all pattern for a playlist; called after a sync delta lands. */
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId") @Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
suspend fun deleteByPlaylist(playlistId: String) suspend fun deleteByPlaylist(playlistId: String)
/**
* Replaces a playlist's whole membership in ONE transaction; called
* after a refresh or a sync delta lands.
*
* Atomic on purpose. Room's InvalidationTracker only notifies observers
* after the transaction commits, so [observeByPlaylist] never sees the
* empty gap between the delete and the re-insert. Un-transacted, that
* gap is a real observed state — it's what made every Home row visibly
* collapse to empty and refill before issue #2327 fixed the equivalent
* write in `CachedHomeIndexDao`.
*
* Nothing observes [observeByPlaylist] live today, so this is
* pre-emptive: it means making playlist detail cache-first later can't
* silently reintroduce that flicker.
*/
@Transaction
suspend fun replacePlaylistTracks(
playlistId: String,
rows: List<CachedPlaylistTrackEntity>,
) {
deleteByPlaylist(playlistId)
if (rows.isNotEmpty()) upsertAll(rows)
}
@Query( @Query(
"DELETE FROM cached_playlist_tracks " + "DELETE FROM cached_playlist_tracks " +
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)", "WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
@@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor(
throw e throw e
} }
playlistDao.upsertAll(listOf(wire.toPlaylistEntity())) playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
playlistTrackDao.deleteByPlaylist(id) playlistTrackDao.replacePlaylistTracks(
playlistTrackDao.upsertAll( playlistId = id,
wire.tracks.mapNotNull { row -> rows = wire.tracks.mapNotNull { row ->
row.trackId?.let { trackId -> row.trackId?.let { trackId ->
CachedPlaylistTrackEntity( CachedPlaylistTrackEntity(
playlistId = id, playlistId = id,
+24 -11
View File
@@ -9,11 +9,17 @@ Minstrel's four workflows consume two CI images:
``` ```
git.fabledsword.com/bvandeusen/ci-go:1.26 git.fabledsword.com/bvandeusen/ci-go:1.26
git.fabledsword.com/bvandeusen/ci-flutter:3.44 git.fabledsword.com/bvandeusen/ci-android:36
``` ```
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`.gitea/workflows/release.yml`). - `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job).
- `ci-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.yml`). - `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job).
**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter
client with the native Android app and `flutter.yml` was removed; `ci-android`
took its place. `flutter_client/` is still in the tree but nothing builds it.
CI-Runner still publishes `ci-flutter` and will retire it once that directory
goes — so if the Flutter client is ever revived, say so there first.
## Image deps used ## Image deps used
@@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44
- **docker buildx** — release container build + push in `release.yml`. - **docker buildx** — release container build + push in `release.yml`.
- **curl** — release-asset polling / upload in `release.yml`. - **curl** — release-asset polling / upload in `release.yml`.
### From `ci-flutter:3.44` ### From `ci-android:36`
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release). - **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen). `android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"`
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing. error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
- **Java 25** — Gradle / Android build. to silence Gradle's launcher-JVM restricted-method warning.
- **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing.
No NDK: the native client has no C/C++ sources (this is why it isn't on
`ci-flutter`).
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
`android.yml`. Image pins track `android/gradle/libs.versions.toml` so local
and CI checks agree.
- **git** — `actions/checkout@v4` baseline (and any shell git operations). - **git** — `actions/checkout@v4` baseline (and any shell git operations).
- **base64 + curl** — keystore decode + release-asset upload in the tag-build path. - **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
`android-release` job.
## Per-job tool installs ## Per-job tool installs
@@ -39,10 +52,10 @@ None.
## Notes ## Notes
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions. - **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step. - **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows. - **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure). - **In-app update channel `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action. - **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.** - **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
```yaml ```yaml
+5
View File
@@ -104,6 +104,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Get("/search", h.handleSearch) authed.Get("/search", h.handleSearch)
authed.Get("/radio", h.handleRadio) authed.Get("/radio", h.handleRadio)
authed.Get("/discover/suggestions", h.handleListSuggestions) authed.Get("/discover/suggestions", h.handleListSuggestions)
// Snooze = "not right now", time-boxed and self-expiring
// (#2374). Not a dislike — see the migration for why.
authed.Post("/discover/suggestions/{mbid}/snooze", h.handleSnoozeSuggestion)
authed.Delete("/discover/suggestions/{mbid}/snooze", h.handleUnsnoozeSuggestion)
authed.Get("/discover/snoozes", h.handleListSuggestionSnoozes)
authed.Get("/home", h.handleGetHome) authed.Get("/home", h.handleGetHome)
authed.Get("/home/index", h.handleGetHomeIndex) authed.Get("/home/index", h.handleGetHomeIndex)
authed.Post("/events", h.handleEvents) authed.Post("/events", h.handleEvents)
+150
View File
@@ -2,13 +2,19 @@ package api
import ( import (
"context" "context"
"encoding/json"
"errors"
"io"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"sync" "sync"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr" "git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
) )
@@ -92,6 +98,150 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
} }
// Snooze duration bounds. 90 days is long enough that a parked suggestion
// stops feeling like it's nagging, short enough that a taste shift brings it
// back on its own — the whole point of a snooze over a dismissal (#2374).
const (
defaultSnoozeDays = 90.0
maxSnoozeDays = 365.0
)
// snoozeRequest is the POST body. Both fields are optional in the JSON sense
// (an absent body snoozes for the default), but Name is required in practice:
// candidates are out-of-library, so the server has no artists row to resolve a
// display name from and the un-snooze list would have nothing to show. The
// client always has it — it just rendered the card.
type snoozeRequest struct {
Name string `json:"name"`
Days float64 `json:"days"`
}
// snoozeView is one row of GET /api/discover/snoozes.
type snoozeView struct {
MBID string `json:"mbid"`
Name string `json:"name"`
SnoozedUntil pgtype.Timestamptz `json:"snoozed_until"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// handleSnoozeSuggestion implements
// POST /api/discover/suggestions/{mbid}/snooze.
//
// Parks a candidate for `days` (default 90, capped at 365). Idempotent:
// snoozing an already-snoozed candidate extends it rather than conflicting.
//
// This is NOT negative feedback. It records no verdict on the artist and is
// never read by internal/taste — see 0049_suggestion_snoozes.up.sql for the
// rule #101 reasoning. Returns 204.
func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
if mbid == "" {
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
return
}
// An empty body is a valid "snooze this for the default period", so EOF
// is not an error here — decodeBody would reject it as a malformed body.
var body snoozeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeErr(w, apierror.BadRequest("invalid_body", ""))
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
writeErr(w, apierror.BadRequest("invalid_body", "name is required"))
return
}
days := body.Days
if days <= 0 {
days = defaultSnoozeDays
}
if days > maxSnoozeDays {
// Clamp rather than reject: a client asking for longer than we allow
// still means "park this", and failing the write would leave the card
// sitting there as if the tap did nothing.
days = maxSnoozeDays
}
q := dbq.New(h.pool)
if err := q.SnoozeSuggestion(r.Context(), dbq.SnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: mbid,
CandidateName: name,
Column4: days,
}); err != nil {
h.logger.Error("api: snooze suggestion", "err", err)
writeErr(w, apierror.InternalMsg("failed to snooze suggestion", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnsnoozeSuggestion implements
// DELETE /api/discover/suggestions/{mbid}/snooze.
//
// Brings a parked candidate back immediately. 404s an MBID this user never
// snoozed, so the client can tell "undone" from "there was nothing there".
func (h *handlers) handleUnsnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
if mbid == "" {
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
return
}
q := dbq.New(h.pool)
rows, err := q.UnsnoozeSuggestion(r.Context(), dbq.UnsnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: mbid,
})
if err != nil {
h.logger.Error("api: unsnooze suggestion", "err", err)
writeErr(w, apierror.InternalMsg("failed to unsnooze suggestion", err))
return
}
if rows == 0 {
writeErr(w, apierror.NotFound("snooze"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleListSuggestionSnoozes implements GET /api/discover/snoozes.
//
// The un-snooze surface needs this: a parked candidate is by definition
// absent from the suggestion deck, so without a list there is no way to
// reach the DELETE above. Scoped to the caller (rule #47). Expired rows are
// already filtered by the query — the hourly gc sweep only reclaims space.
func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbq.New(h.pool).ListActiveSuggestionSnoozes(r.Context(), user.ID)
if err != nil {
h.logger.Error("api: list suggestion snoozes", "err", err)
writeErr(w, apierror.InternalMsg("failed to load snoozes", err))
return
}
out := make([]snoozeView, 0, len(rows))
for _, row := range rows {
out = append(out, snoozeView{
MBID: row.CandidateMbid,
Name: row.CandidateName,
SnoozedUntil: row.SnoozedUntil,
CreatedAt: row.CreatedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist // resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free: // lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
// Lidarr is the only source — when it's disabled, unreachable, or has // Lidarr is the only source — when it's disabled, unreachable, or has
+8
View File
@@ -518,6 +518,14 @@ type SmtpConfig struct {
UpdatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz
} }
type SuggestionSnooze struct {
UserID pgtype.UUID
CandidateMbid string
CandidateName string
SnoozedUntil pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
type SystemPlaylistRotationState struct { type SystemPlaylistRotationState struct {
UserID pgtype.UUID UserID pgtype.UUID
PlaylistKind string PlaylistKind string
+66 -13
View File
@@ -1022,20 +1022,50 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
} }
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
WITH seeds AS ( WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
),
profile_seeds AS (
SELECT tpa.artist_id, tpa.weight AS raw_signal
FROM taste_profile_artists tpa
WHERE tpa.user_id = $1 AND tpa.weight > 0
),
fallback_seeds AS (
SELECT a.id AS artist_id, SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END) 5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0) + COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
AS signal, AS raw_signal
(gla.artist_id IS NOT NULL) AS is_liked,
COUNT(pe.id)::bigint AS play_count
FROM artists a FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 LEFT JOIN play_events pe
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
GROUP BY a.id, gla.artist_id GROUP BY a.id, gla.artist_id
), ),
seeds AS (
SELECT s.artist_id,
ln(1.0 + s.raw_signal) AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COALESCE(ap.play_count, 0)::bigint AS play_count
FROM (
SELECT artist_id, raw_signal FROM profile_seeds
UNION ALL
SELECT artist_id, raw_signal FROM fallback_seeds
) s
LEFT JOIN general_likes_artists gla
ON gla.artist_id = s.artist_id AND gla.user_id = $1
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
WHERE s.raw_signal > 0
),
contributions AS ( contributions AS (
SELECT u.candidate_mbid, SELECT u.candidate_mbid,
u.candidate_name, u.candidate_name,
@@ -1052,6 +1082,18 @@ contributions AS (
AND r.lidarr_artist_mbid = u.candidate_mbid AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed') AND r.status NOT IN ('rejected', 'failed')
) )
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
-- per-user: the candidate returns on its own once snoozed_until
-- passes, and stays visible to everyone else meanwhile. Deliberately
-- filtered at the candidate stage, NOT folded into the score — a
-- snooze carries no opinion about the music, so it must not become a
-- ranking signal.
AND NOT EXISTS (
SELECT 1 FROM suggestion_snoozes s
WHERE s.user_id = $1
AND s.candidate_mbid = u.candidate_mbid
AND s.snoozed_until > now()
)
) )
SELECT candidate_mbid, SELECT candidate_mbid,
candidate_name, candidate_name,
@@ -1082,13 +1124,24 @@ type SuggestArtistsForUserRow struct {
TopPlayCounts []int64 TopPlayCounts []int64
} }
// M5c: per-user artist suggestions ranked by signal x similarity. The // Per-user artist suggestions ranked by taste signal x similarity, projected
// seeds CTE collects the user's likes (x5) plus recency-decayed plays // through artist_similarity_unmatched (out-of-library candidates only).
// (exp(-age_days / $2)). The contributions CTE joins those seeds against //
// artist_similarity_unmatched and filters out candidates already in // Seeds are TIERED (rule #131) so the surface never empties:
// library or already in a non-terminal lidarr_request. The outer SELECT //
// aggregates per candidate, returning the top-3 contributing seeds for // tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
// attribution. $1=user_id, $2=half_life_days, $3=limit. // SIGNED by internal/taste, so an artist the user has drifted away
// from stops contributing instead of accumulating forever.
// tier 2 - likes + completed plays, used ONLY when the profile has no rows
// (new account, or before the first daily recompute).
//
// The signal is log-damped: contribution is signal x similarity, and the old
// undamped sum let one heavily-played artist's neighbours take every slot --
// entrenching harder the MORE the user listened (issue #2367 mechanism 2).
//
// Candidates already in the library, already requested and not terminal, or
// snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
// decay), $3=limit.
func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) { func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit) rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
if err != nil { if err != nil {
+126
View File
@@ -0,0 +1,126 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: suggestion_snoozes.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const gcDeleteExpiredSuggestionSnoozes = `-- name: GcDeleteExpiredSuggestionSnoozes :execrows
DELETE FROM suggestion_snoozes WHERE snoozed_until < now()
`
// Keeps the table from growing without bound. Every read already filters on
// snoozed_until > now(), so deleting an expired row changes no behaviour —
// this is purely reclamation.
func (q *Queries) GcDeleteExpiredSuggestionSnoozes(ctx context.Context) (int64, error) {
result, err := q.db.Exec(ctx, gcDeleteExpiredSuggestionSnoozes)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
const listActiveSuggestionSnoozes = `-- name: ListActiveSuggestionSnoozes :many
SELECT candidate_mbid, candidate_name, snoozed_until, created_at
FROM suggestion_snoozes
WHERE user_id = $1 AND snoozed_until > now()
ORDER BY snoozed_until, candidate_mbid
`
type ListActiveSuggestionSnoozesRow struct {
CandidateMbid string
CandidateName string
SnoozedUntil pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
// Backs the manage / un-snooze surface. Expired rows are filtered HERE
// rather than left to the sweeper: gc runs on an hourly tick, so a row can
// outlive its expiry by up to a tick and must not read as still-snoozed in
// the meantime.
func (q *Queries) ListActiveSuggestionSnoozes(ctx context.Context, userID pgtype.UUID) ([]ListActiveSuggestionSnoozesRow, error) {
rows, err := q.db.Query(ctx, listActiveSuggestionSnoozes, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActiveSuggestionSnoozesRow
for rows.Next() {
var i ListActiveSuggestionSnoozesRow
if err := rows.Scan(
&i.CandidateMbid,
&i.CandidateName,
&i.SnoozedUntil,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const snoozeSuggestion = `-- name: SnoozeSuggestion :exec
INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
VALUES ($1, $2, $3, now() + ($4::float8 * INTERVAL '1 day'))
ON CONFLICT (user_id, candidate_mbid) DO UPDATE
SET snoozed_until = EXCLUDED.snoozed_until,
candidate_name = EXCLUDED.candidate_name
`
type SnoozeSuggestionParams struct {
UserID pgtype.UUID
CandidateMbid string
CandidateName string
Column4 float64
}
// Time-boxed "not right now" on a Discover artist suggestion (#2374).
//
// See 0049_suggestion_snoozes.up.sql for why this is a snooze and not a
// dismissal: it records no verdict on the music, expires on its own, and
// must never reach the taste profile. Nothing in internal/taste may read
// this table.
// Upsert, so re-snoozing an already-snoozed candidate EXTENDS it instead of
// erroring on the PK. The name is refreshed too — a later suggestion may
// carry a corrected spelling from the similarity feed.
// $1=user_id, $2=candidate_mbid, $3=candidate_name, $4=duration in days.
func (q *Queries) SnoozeSuggestion(ctx context.Context, arg SnoozeSuggestionParams) error {
_, err := q.db.Exec(ctx, snoozeSuggestion,
arg.UserID,
arg.CandidateMbid,
arg.CandidateName,
arg.Column4,
)
return err
}
const unsnoozeSuggestion = `-- name: UnsnoozeSuggestion :execrows
DELETE FROM suggestion_snoozes
WHERE user_id = $1 AND candidate_mbid = $2
`
type UnsnoozeSuggestionParams struct {
UserID pgtype.UUID
CandidateMbid string
}
// Row count is returned so the handler can 404 an MBID that was never
// snoozed rather than reporting success for a no-op.
func (q *Queries) UnsnoozeSuggestion(ctx context.Context, arg UnsnoozeSuggestionParams) (int64, error) {
result, err := q.db.Exec(ctx, unsnoozeSuggestion, arg.UserID, arg.CandidateMbid)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS suggestion_snoozes_expiry_idx;
DROP TABLE IF EXISTS suggestion_snoozes;
@@ -0,0 +1,33 @@
-- 0049_suggestion_snoozes.up.sql — time-boxed "not right now" on a Discover
-- artist suggestion (#2374, milestone #268 slice 3).
--
-- This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down /
-- exclusion UI, and a snooze deliberately isn't one: it records no verdict on
-- the music, expires on its own, and MUST NEVER feed the taste profile. It is
-- acquisition triage — "I don't want to request this right now" — so the same
-- candidate is free to return once snoozed_until passes. Anything that reads
-- this table as negative preference signal is a bug.
--
-- Per-user (rule #47), never global: one household member parking a
-- suggestion must not remove it from anyone else's deck.
--
-- candidate_mbid is text with NO foreign key, on purpose. Suggestions come
-- from artist_similarity_unmatched and are out-of-library BY DEFINITION, so
-- there is no artists row to reference — the MBID is the only stable identity
-- available. candidate_name is denormalized for the same reason: the manage /
-- un-snooze list has nowhere else to resolve a display name from.
CREATE TABLE suggestion_snoozes (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
candidate_mbid text NOT NULL,
candidate_name text NOT NULL,
snoozed_until timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, candidate_mbid)
);
-- Supports the gc sweep's unqualified `WHERE snoozed_until < now()` scan. The
-- composite PK already covers every per-user read, so this is the only extra
-- index worth its write cost at household-scale row counts (same reasoning as
-- lidarr_quarantine in 0011).
CREATE INDEX suggestion_snoozes_expiry_idx ON suggestion_snoozes (snoozed_until);
+65 -13
View File
@@ -258,27 +258,67 @@ ORDER BY started_at DESC
LIMIT 1; LIMIT 1;
-- name: SuggestArtistsForUser :many -- name: SuggestArtistsForUser :many
-- M5c: per-user artist suggestions ranked by signal x similarity. The -- Per-user artist suggestions ranked by taste signal x similarity, projected
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays -- through artist_similarity_unmatched (out-of-library candidates only).
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against --
-- artist_similarity_unmatched and filters out candidates already in -- Seeds are TIERED (rule #131) so the surface never empties:
-- library or already in a non-terminal lidarr_request. The outer SELECT -- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
-- aggregates per candidate, returning the top-3 contributing seeds for -- SIGNED by internal/taste, so an artist the user has drifted away
-- attribution. $1=user_id, $2=half_life_days, $3=limit. -- from stops contributing instead of accumulating forever.
WITH seeds AS ( -- tier 2 - likes + completed plays, used ONLY when the profile has no rows
-- (new account, or before the first daily recompute).
--
-- The signal is log-damped: contribution is signal x similarity, and the old
-- undamped sum let one heavily-played artist's neighbours take every slot --
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
--
-- Candidates already in the library, already requested and not terminal, or
-- snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
-- decay), $3=limit.
WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
),
profile_seeds AS (
SELECT tpa.artist_id, tpa.weight AS raw_signal
FROM taste_profile_artists tpa
WHERE tpa.user_id = $1 AND tpa.weight > 0
),
fallback_seeds AS (
SELECT a.id AS artist_id, SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END) 5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0) + COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
AS signal, AS raw_signal
(gla.artist_id IS NOT NULL) AS is_liked,
COUNT(pe.id)::bigint AS play_count
FROM artists a FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1 LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1 LEFT JOIN play_events pe
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
GROUP BY a.id, gla.artist_id GROUP BY a.id, gla.artist_id
), ),
seeds AS (
SELECT s.artist_id,
ln(1.0 + s.raw_signal) AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COALESCE(ap.play_count, 0)::bigint AS play_count
FROM (
SELECT artist_id, raw_signal FROM profile_seeds
UNION ALL
SELECT artist_id, raw_signal FROM fallback_seeds
) s
LEFT JOIN general_likes_artists gla
ON gla.artist_id = s.artist_id AND gla.user_id = $1
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
WHERE s.raw_signal > 0
),
contributions AS ( contributions AS (
SELECT u.candidate_mbid, SELECT u.candidate_mbid,
u.candidate_name, u.candidate_name,
@@ -295,6 +335,18 @@ contributions AS (
AND r.lidarr_artist_mbid = u.candidate_mbid AND r.lidarr_artist_mbid = u.candidate_mbid
AND r.status NOT IN ('rejected', 'failed') AND r.status NOT IN ('rejected', 'failed')
) )
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
-- per-user: the candidate returns on its own once snoozed_until
-- passes, and stays visible to everyone else meanwhile. Deliberately
-- filtered at the candidate stage, NOT folded into the score — a
-- snooze carries no opinion about the music, so it must not become a
-- ranking signal.
AND NOT EXISTS (
SELECT 1 FROM suggestion_snoozes s
WHERE s.user_id = $1
AND s.candidate_mbid = u.candidate_mbid
AND s.snoozed_until > now()
)
) )
SELECT candidate_mbid, SELECT candidate_mbid,
candidate_name, candidate_name,
@@ -0,0 +1,39 @@
-- Time-boxed "not right now" on a Discover artist suggestion (#2374).
--
-- See 0049_suggestion_snoozes.up.sql for why this is a snooze and not a
-- dismissal: it records no verdict on the music, expires on its own, and
-- must never reach the taste profile. Nothing in internal/taste may read
-- this table.
-- name: SnoozeSuggestion :exec
-- Upsert, so re-snoozing an already-snoozed candidate EXTENDS it instead of
-- erroring on the PK. The name is refreshed too — a later suggestion may
-- carry a corrected spelling from the similarity feed.
-- $1=user_id, $2=candidate_mbid, $3=candidate_name, $4=duration in days.
INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
VALUES ($1, $2, $3, now() + ($4::float8 * INTERVAL '1 day'))
ON CONFLICT (user_id, candidate_mbid) DO UPDATE
SET snoozed_until = EXCLUDED.snoozed_until,
candidate_name = EXCLUDED.candidate_name;
-- name: UnsnoozeSuggestion :execrows
-- Row count is returned so the handler can 404 an MBID that was never
-- snoozed rather than reporting success for a no-op.
DELETE FROM suggestion_snoozes
WHERE user_id = $1 AND candidate_mbid = $2;
-- name: ListActiveSuggestionSnoozes :many
-- Backs the manage / un-snooze surface. Expired rows are filtered HERE
-- rather than left to the sweeper: gc runs on an hourly tick, so a row can
-- outlive its expiry by up to a tick and must not read as still-snoozed in
-- the meantime.
SELECT candidate_mbid, candidate_name, snoozed_until, created_at
FROM suggestion_snoozes
WHERE user_id = $1 AND snoozed_until > now()
ORDER BY snoozed_until, candidate_mbid;
-- name: GcDeleteExpiredSuggestionSnoozes :execrows
-- Keeps the table from growing without bound. Every read already filters on
-- snoozed_until > now(), so deleting an expired row changes no behaviour —
-- this is purely reclamation.
DELETE FROM suggestion_snoozes WHERE snoozed_until < now();
+6
View File
@@ -52,6 +52,12 @@ var dataTables = []string{
"sessions", "sessions",
"lidarr_quarantine_actions", "lidarr_quarantine_actions",
"lidarr_quarantine", "lidarr_quarantine",
// #2374. Keyed by (user_id, candidate_mbid) with no FK to artists —
// candidates are out-of-library — so the CASCADE from artists/users
// does NOT reach it for a leftover row whose user survived. Truncate
// explicitly or a stale snooze silently hides a candidate from the
// next test's suggestion assertions.
"suggestion_snoozes",
"playlist_tracks", "playlist_tracks",
"playlists", "playlists",
"library_changes", // M7 #357 — must reset to keep cursor isolated per test "library_changes", // M7 #357 — must reset to keep cursor isolated per test
+2
View File
@@ -17,6 +17,7 @@
// - GcResetStuckSystemPlaylistRuns (#574) // - GcResetStuckSystemPlaylistRuns (#574)
// - GcDeleteExpiredPasswordResets (#575) // - GcDeleteExpiredPasswordResets (#575)
// - GcPruneDiagnostics (M9 — diagnostics 30d retention) // - GcPruneDiagnostics (M9 — diagnostics 30d retention)
// - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go)
package gc package gc
import ( import (
@@ -84,6 +85,7 @@ func (w *Worker) tickOnce(ctx context.Context) {
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns) w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets) w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics) w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
w.runSweep(ctx, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes)
} }
// runSweep is a small adapter so each sweep call site is a one-liner // runSweep is a small adapter so each sweep call site is a one-liner
+155 -8
View File
@@ -1,12 +1,23 @@
// suggestions.go is the M5c per-user artist-suggestion service. Reads // suggestions.go is the per-user artist-suggestion service behind the
// the user's likes + plays, projects them through artist_similarity_unmatched // Discover request surface. Seeds from the taste profile (falling back to
// via a single CTE, returns top-N candidates with top-3 attribution seeds // likes + completed plays for a user who has none yet), projects those seeds
// resolved to artist names. // through artist_similarity_unmatched via a single CTE, and returns top-N
// out-of-library candidates with top-3 attribution seeds resolved to names.
//
// Originally M5c, which seeded from raw likes + plays. That signal grew
// without bound and counted skips as engagement, so a few heavily-played
// artists monopolized every slot and the surface entrenched harder the more
// the user listened. Reworked to seed from the taste profile in issue #2367;
// see internal/db/queries/recommendation.sql for the tiering.
package recommendation package recommendation
import ( import (
"context" "context"
"crypto/md5"
"encoding/hex"
"fmt" "fmt"
"sort"
"time"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -32,8 +43,12 @@ type SeedContribution struct {
} }
// SuggestArtists returns top-N artist suggestions for the user. limit is // SuggestArtists returns top-N artist suggestions for the user. limit is
// capped at 50 (default 12 when out of range); halfLifeDays is the // capped at 50 (default 12 when out of range).
// recency-decay half-life for plays (default 30, operator-tunable). //
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
// seed path only — the likes + completed-plays fallback used while the user
// has no taste-profile rows yet. Once the profile is populated it seeds
// instead, carrying its own decay, so this knob stops applying.
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) { func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
if limit <= 0 || limit > 50 { if limit <= 0 || limit > 50 {
limit = 12 limit = 12
@@ -42,10 +57,14 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
halfLifeDays = 30 halfLifeDays = 30
} }
q := dbq.New(pool) q := dbq.New(pool)
// Over-fetch so there is something to rotate through. 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 unreachable (#2367
// mechanism 1). Selection from this pool happens in selectSuggestions.
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{ rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
UserID: userID, UserID: userID,
Column2: halfLifeDays, Column2: halfLifeDays,
Limit: int32(limit), Limit: int32(poolSizeFor(limit)),
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("suggest: query: %w", err) return nil, fmt.Errorf("suggest: query: %w", err)
@@ -98,5 +117,133 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
Attribution: attribution, Attribution: attribution,
}) })
} }
return out, nil return selectSuggestions(out, limit, rotationDay(time.Now())), nil
}
// Pool multiplier: how many scored candidates to fetch per slot shown, so the
// rotation has somewhere to rotate. 4x keeps a day's deck genuinely different
// from yesterday's without pulling the whole long tail (whose scores are noise)
// into every request.
const suggestionPoolFactor = 4
// Hard ceiling on the fetched pool. The scored tail past this is low-signal, so
// paying for it would buy churn rather than better suggestions.
const suggestionPoolMax = 60
func poolSizeFor(limit int) int {
n := limit * suggestionPoolFactor
if n > suggestionPoolMax {
return suggestionPoolMax
}
return n
}
// rotationDay is the bucket the daily rotation hashes against. Server-local
// date, matching the `current_date` the Home rows already rotate on, so the
// whole product turns over at the same moment.
func rotationDay(now time.Time) string {
return now.Format("2006-01-02")
}
// selectSuggestions turns the scored pool into the response.
//
// Pure by design — no DB, no clock — so the rotation and diversity rules are
// unit-testable without Postgres. Callers pass the day bucket in.
//
// Three rules, in order:
//
// 1. Diversity cap. Walking in score order, a candidate is dropped once its
// dominant seed already owns maxPerSeed slots. Nothing capped this before,
// so all twelve slots could be neighbours of one artist and the surface
// read as a single narrow cluster (#2367 mechanism 4).
// 2. Head. The best few by score always lead, so the strongest matches are
// never rotated out of sight. Mirrors For You's head/tail shape.
// 3. Tail. The rest of the slots are drawn from the remaining pool ordered by
// md5(mbid + day) — the same daily-stable idiom the Home rows use. Stable
// within a day, different tomorrow, and it needs no stored state.
func selectSuggestions(pool []ArtistSuggestion, limit int, day string) []ArtistSuggestion {
if len(pool) <= limit {
return pool
}
preferred, overflow := partitionPerSeed(pool, maxPerSeedFor(limit))
headCount := limit / 3
if headCount > len(preferred) {
headCount = len(preferred)
}
out := make([]ArtistSuggestion, 0, limit)
out = append(out, preferred[:headCount]...)
// Hash once per candidate, not once per comparison.
rest := preferred[headCount:]
tail := make([]rotatable, 0, len(rest))
for _, s := range rest {
tail = append(tail, rotatable{key: rotationKey(s.MBID, day), suggestion: s})
}
sort.SliceStable(tail, func(i, j int) bool { return tail[i].key < tail[j].key })
for _, r := range tail {
if len(out) >= limit {
break
}
out = append(out, r.suggestion)
}
// Diversity is a PREFERENCE, not a quota that may starve the deck. A user
// whose whole pool hangs off one or two seeds would otherwise get a
// three-card surface, which is worse than the monoculture we were avoiding
// (and is the vanish-or-nothing shape rule #131 exists to prevent). Top up
// in score order from what the cap set aside.
for _, s := range overflow {
if len(out) >= limit {
break
}
out = append(out, s)
}
return out
}
// rotatable pairs a candidate with its precomputed daily rotation key.
type rotatable struct {
key string
suggestion ArtistSuggestion
}
// maxPerSeedFor keeps roughly a quarter of the deck attributable to any single
// seed artist — enough for a strong affinity to be well represented, not enough
// for it to BE the deck. Floored at 1 so a small limit still returns something.
func maxPerSeedFor(limit int) int {
n := limit / 4
if n < 1 {
return 1
}
return n
}
// partitionPerSeed splits the pool by whether each candidate's dominant
// (highest-contributing) seed still has allowance left. Input must be in score
// order; both outputs preserve it.
//
// Candidates with no attribution are always preferred — there is no seed to
// attribute them to, so they cannot be the cause of a monoculture.
func partitionPerSeed(pool []ArtistSuggestion, maxPerSeed int) (preferred, overflow []ArtistSuggestion) {
perSeed := make(map[pgtype.UUID]int, len(pool))
preferred = make([]ArtistSuggestion, 0, len(pool))
for _, s := range pool {
if len(s.Attribution) == 0 {
preferred = append(preferred, s)
continue
}
dominant := s.Attribution[0].ArtistID
if perSeed[dominant] >= maxPerSeed {
overflow = append(overflow, s)
continue
}
perSeed[dominant]++
preferred = append(preferred, s)
}
return preferred, overflow
}
func rotationKey(mbid, day string) string {
sum := md5.Sum([]byte(mbid + day))
return hex.EncodeToString(sum[:])
} }
@@ -330,3 +330,359 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
t.Errorf("len = %d, want 0 (new user has no signal)", len(out)) t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
} }
} }
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
userID, artistID, weight,
); err != nil {
t.Fatalf("set taste weight: %v", err)
}
}
func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
t.Helper()
ctx := context.Background()
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, 'skip-test') RETURNING id`,
userID, startedAt,
).Scan(&sessionID); err != nil {
t.Fatalf("insert play_session: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
VALUES ($1, $2, $3, $4, true)`,
userID, trackID, sessionID, startedAt,
); err != nil {
t.Fatalf("insert skipped play_event: %v", err)
}
}
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
// play on the seed artist. Before #2367 the profile was ignored entirely here.
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seed := seedArtist(t, pool, "Profile Seed", "")
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Fatalf("len = %d, want 1 (taste weight alone should seed)", len(out))
}
if out[0].MBID != "out-mbid" {
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
}
if out[0].Score <= 0 {
t.Errorf("score = %v, want > 0", out[0].Score)
}
}
// Once the profile has any positive row, tier 2 is not consulted — so an artist
// the user played but that the taste engine did not keep does NOT seed. That is
// the point of the rewrite: the taste engine decides what counts as affinity.
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
kept := seedArtist(t, pool, "Kept By Taste", "")
dropped := seedArtist(t, pool, "Dropped By Taste", "")
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
// `dropped` has real completed plays but no profile row.
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Fatalf("len = %d, want 1 (tier 2 must not run alongside tier 1)", len(out))
}
if out[0].MBID != "kept-cand" {
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
}
}
// A non-positive taste weight is not affinity. Guarded by a second, positive
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
// tier 2 and the assertion would pass for the wrong reason.
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
positive := seedArtist(t, pool, "Still Liked", "")
abandoned := seedArtist(t, pool, "Abandoned", "")
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
for _, s := range out {
if s.MBID == "neg-cand" {
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
}
}
if len(out) != 1 || out[0].MBID != "pos-cand" {
t.Errorf("out = %+v, want only pos-cand", out)
}
}
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
// skipping an artist repeatedly increased its signal and pushed more of its
// neighbours at the user (#2367 mechanism 3).
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
skipped := seedArtist(t, pool, "Only Skipped", "")
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
for i := 0; i < 5; i++ {
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
}
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 0 {
t.Errorf("len = %d, want 0 (skips are not affinity): %+v", len(out), out)
}
}
// --- Slice 3 (#2374): time-boxed suggestion snooze ---
//
// The defining property under test is that a snooze EXPIRES. A permanent
// dismissal would pass most of these; only TestSuggestArtists_ExpiredSnooze
// distinguishes the two, and it is the reason this shape was approved over an
// exclusion UI (rule #101).
// snoozeUntil inserts a snooze row with an explicit absolute expiry, so a
// test can place it in the past without depending on the duration arithmetic
// in SnoozeSuggestion.
func snoozeUntil(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string, until time.Time) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, candidate_mbid) DO UPDATE SET snoozed_until = EXCLUDED.snoozed_until`,
userID, mbid, name, until,
); err != nil {
t.Fatalf("snoozeUntil: %v", err)
}
}
// seedOneCandidate wires the minimum that puts exactly one candidate in the
// deck: a liked seed artist with one unmatched neighbour.
func seedOneCandidate(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string) {
t.Helper()
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, userID, seed.ID)
seedUnmatched(t, pool, seed.ID, mbid, name, 0.9)
}
func TestSuggestArtists_ActiveSnoozeHidesCandidate(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seedOneCandidate(t, pool, user.ID, "snoozed-mbid", "Parked Artist")
if err := dbq.New(pool).SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: "snoozed-mbid",
CandidateName: "Parked Artist",
Column4: 90,
}); err != nil {
t.Fatalf("SnoozeSuggestion: %v", err)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 0 {
t.Errorf("len = %d, want 0 (active snooze should hide the candidate): %+v", len(out), out)
}
}
// The whole point of a snooze over a dismissal: it comes back on its own.
func TestSuggestArtists_ExpiredSnoozeShowsCandidateAgain(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seedOneCandidate(t, pool, user.ID, "expired-mbid", "Returning Artist")
snoozeUntil(t, pool, user.ID, "expired-mbid", "Returning Artist", time.Now().Add(-time.Hour))
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Fatalf("len = %d, want 1 (an expired snooze must not hide anything): %+v", len(out), out)
}
if out[0].MBID != "expired-mbid" {
t.Errorf("mbid = %q, want expired-mbid", out[0].MBID)
}
}
// Rule #47: one household member parking a suggestion must not remove it
// from anyone else's deck.
func TestSuggestArtists_SnoozeIsPerUser(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, alice.ID, seed.ID)
likeArtist(t, pool, bob.ID, seed.ID)
seedUnmatched(t, pool, seed.ID, "shared-mbid", "Shared Candidate", 0.9)
snoozeUntil(t, pool, alice.ID, "shared-mbid", "Shared Candidate", time.Now().Add(24*time.Hour))
aliceOut, err := SuggestArtists(context.Background(), pool, alice.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists(alice): %v", err)
}
if len(aliceOut) != 0 {
t.Errorf("alice len = %d, want 0 (she snoozed it)", len(aliceOut))
}
bobOut, err := SuggestArtists(context.Background(), pool, bob.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists(bob): %v", err)
}
if len(bobOut) != 1 {
t.Errorf("bob len = %d, want 1 (alice's snooze is not his)", len(bobOut))
}
}
func TestUnsnoozeSuggestion_RestoresImmediately(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
seedOneCandidate(t, pool, user.ID, "undo-mbid", "Undo Artist")
snoozeUntil(t, pool, user.ID, "undo-mbid", "Undo Artist", time.Now().Add(90*24*time.Hour))
q := dbq.New(pool)
rows, err := q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
UserID: user.ID, CandidateMbid: "undo-mbid",
})
if err != nil {
t.Fatalf("UnsnoozeSuggestion: %v", err)
}
if rows != 1 {
t.Errorf("rows = %d, want 1", rows)
}
// A second delete affects nothing — the handler turns this into a 404
// rather than reporting success for a no-op.
rows, err = q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
UserID: user.ID, CandidateMbid: "undo-mbid",
})
if err != nil {
t.Fatalf("UnsnoozeSuggestion (repeat): %v", err)
}
if rows != 0 {
t.Errorf("repeat rows = %d, want 0", rows)
}
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 1 {
t.Errorf("len = %d, want 1 (unsnooze restores the candidate)", len(out))
}
}
// Re-snoozing must extend, not conflict on the PK.
func TestSnoozeSuggestion_UpsertExtends(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
q := dbq.New(pool)
snoozeUntil(t, pool, user.ID, "extend-mbid", "Old Name", time.Now().Add(time.Hour))
if err := q.SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: "extend-mbid",
CandidateName: "New Name",
Column4: 90,
}); err != nil {
t.Fatalf("SnoozeSuggestion (re-snooze): %v", err)
}
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
if err != nil {
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
}
if len(rows) != 1 {
t.Fatalf("len = %d, want 1 (upsert, not a second row)", len(rows))
}
if rows[0].CandidateName != "New Name" {
t.Errorf("name = %q, want New Name (upsert refreshes it)", rows[0].CandidateName)
}
if got := time.Until(rows[0].SnoozedUntil.Time); got < 80*24*time.Hour {
t.Errorf("snoozed_until is %v away, want ~90d (re-snooze should extend)", got)
}
}
// ListActiveSuggestionSnoozes filters expired rows itself rather than
// trusting the hourly gc sweep to have run.
func TestListActiveSuggestionSnoozes_ExcludesExpired(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
rows, err := dbq.New(pool).ListActiveSuggestionSnoozes(context.Background(), user.ID)
if err != nil {
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
}
if len(rows) != 1 {
t.Fatalf("len = %d, want 1 (expired row must not be listed)", len(rows))
}
if rows[0].CandidateMbid != "live-mbid" {
t.Errorf("mbid = %q, want live-mbid", rows[0].CandidateMbid)
}
}
func TestGcDeleteExpiredSuggestionSnoozes_KeepsActive(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
q := dbq.New(pool)
deleted, err := q.GcDeleteExpiredSuggestionSnoozes(context.Background())
if err != nil {
t.Fatalf("GcDeleteExpiredSuggestionSnoozes: %v", err)
}
if deleted != 1 {
t.Errorf("deleted = %d, want 1 (only the expired row)", deleted)
}
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
if err != nil {
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
}
if len(rows) != 1 {
t.Errorf("len = %d, want 1 (active snooze survives the sweep)", len(rows))
}
}
@@ -0,0 +1,170 @@
package recommendation
import (
"fmt"
"testing"
"github.com/jackc/pgx/v5/pgtype"
)
// selectSuggestions is deliberately pure (no DB, no clock), so the rotation and
// diversity rules from slice #2373 are covered here in the fast lane rather
// than behind the integration gate.
func seedID(n byte) pgtype.UUID {
var u pgtype.UUID
u.Bytes[0] = n
u.Valid = true
return u
}
// candidate builds a pool entry attributed to the given dominant seed.
func candidate(mbid string, score float64, dominant byte) ArtistSuggestion {
return ArtistSuggestion{
MBID: mbid,
Name: mbid,
Score: score,
Attribution: []SeedContribution{
{ArtistID: seedID(dominant), Contribution: score},
},
}
}
// poolOf returns n candidates in descending score order, spread across
// `seeds` distinct dominant seeds.
func poolOf(n int, seeds int) []ArtistSuggestion {
out := make([]ArtistSuggestion, 0, n)
for i := 0; i < n; i++ {
out = append(out, candidate(fmt.Sprintf("mbid-%02d", i), 1.0-float64(i)*0.01, byte(i%seeds)))
}
return out
}
func mbids(in []ArtistSuggestion) []string {
out := make([]string, 0, len(in))
for _, s := range in {
out = append(out, s.MBID)
}
return out
}
func TestSelectSuggestions_ReturnsPoolUnchangedWhenNotOverfetched(t *testing.T) {
pool := poolOf(5, 5)
got := selectSuggestions(pool, 12, "2026-08-01")
if len(got) != 5 {
t.Fatalf("len = %d, want 5 (nothing to rotate)", len(got))
}
if got[0].MBID != "mbid-00" {
t.Errorf("first = %q, want mbid-00", got[0].MBID)
}
}
func TestSelectSuggestions_FillsTheRequestedLimit(t *testing.T) {
got := selectSuggestions(poolOf(48, 8), 12, "2026-08-01")
if len(got) != 12 {
t.Fatalf("len = %d, want 12", len(got))
}
}
// The reported symptom: the deck must change day to day without the user
// requesting anything.
func TestSelectSuggestions_RotatesAcrossDays(t *testing.T) {
pool := poolOf(48, 8)
day1 := mbids(selectSuggestions(pool, 12, "2026-08-01"))
day2 := mbids(selectSuggestions(pool, 12, "2026-08-02"))
if fmt.Sprint(day1) == fmt.Sprint(day2) {
t.Fatalf("deck identical across days: %v", day1)
}
// ...but stable WITHIN a day, or the surface would reshuffle on every
// pull-to-refresh, which reads as broken rather than fresh.
again := mbids(selectSuggestions(pool, 12, "2026-08-01"))
if fmt.Sprint(day1) != fmt.Sprint(again) {
t.Errorf("same day differed:\n %v\n %v", day1, again)
}
}
// The strongest matches should never rotate out of sight.
func TestSelectSuggestions_HeadIsStableTopScorers(t *testing.T) {
pool := poolOf(48, 8)
for _, day := range []string{"2026-08-01", "2026-08-02", "2026-09-15"} {
got := selectSuggestions(pool, 12, day)
if got[0].MBID != "mbid-00" {
t.Errorf("day %s: first = %q, want mbid-00 (top score leads)", day, got[0].MBID)
}
}
}
// #2367 mechanism 4: all twelve slots could be neighbours of one artist.
//
// The pool is deliberately SKEWED — one seed owns the entire top of the score
// ranking — because that is the only shape where a cap can bite. With scores
// spread evenly across seeds the top-N is already diverse and the cap is
// untestable (an earlier version of this test asserted against an even pool and
// could not fail).
func TestSelectSuggestions_CapsOneDominantSeed(t *testing.T) {
const dominantRun = 20
pool := make([]ArtistSuggestion, 0, 48)
for i := 0; i < dominantRun; i++ { // seed 0 owns the 20 best scores
pool = append(pool, candidate(fmt.Sprintf("dom-%02d", i), 1.0-float64(i)*0.01, 0))
}
for i := 0; i < 28; i++ { // seeds 1..9 hold everything below
pool = append(pool, candidate(fmt.Sprintf("oth-%02d", i), 0.80-float64(i)*0.01, byte(1+i%9)))
}
got := selectSuggestions(pool, 12, "2026-08-01")
if len(got) != 12 {
t.Fatalf("len = %d, want 12", len(got))
}
perSeed := map[pgtype.UUID]int{}
for _, s := range got {
perSeed[s.Attribution[0].ArtistID]++
}
// Uncapped, the top 12 by score would be 12 of seed 0's neighbours.
if n := perSeed[seedID(0)]; n > maxPerSeedFor(12) {
t.Errorf("dominant seed owns %d of 12 slots, want <= %d: %v",
n, maxPerSeedFor(12), mbids(got))
}
if len(perSeed) < 4 {
t.Errorf("deck spans only %d seeds, want >= 4: %v", len(perSeed), mbids(got))
}
}
// Diversity must not starve the deck (rule #131): a pool hanging off a single
// seed should still fill, not collapse to maxPerSeed entries.
func TestSelectSuggestions_SingleSeedPoolStillFills(t *testing.T) {
got := selectSuggestions(poolOf(30, 1), 12, "2026-08-01")
if len(got) != 12 {
t.Fatalf("len = %d, want 12 (cap must not starve the deck)", len(got))
}
if got[0].MBID != "mbid-00" {
t.Errorf("first = %q, want mbid-00", got[0].MBID)
}
}
// A candidate with no attribution has no seed to blame, so it must never be
// held back by the diversity cap.
func TestPartitionPerSeed_UnattributedNeverCapped(t *testing.T) {
pool := []ArtistSuggestion{
candidate("a", 0.9, 1),
candidate("b", 0.8, 1),
{MBID: "orphan", Score: 0.1},
}
preferred, overflow := partitionPerSeed(pool, 1)
if len(preferred) != 2 || preferred[1].MBID != "orphan" {
t.Errorf("preferred = %v, want [a orphan]", mbids(preferred))
}
if len(overflow) != 1 || overflow[0].MBID != "b" {
t.Errorf("overflow = %v, want [b]", mbids(overflow))
}
}
func TestPoolSizeFor_OverfetchesButIsBounded(t *testing.T) {
if got := poolSizeFor(12); got != 48 {
t.Errorf("poolSizeFor(12) = %d, want 48", got)
}
if got := poolSizeFor(50); got != suggestionPoolMax {
t.Errorf("poolSizeFor(50) = %d, want the %d ceiling", got, suggestionPoolMax)
}
}