Compare commits
7
Commits
v2026.08.01
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86af79bd2f | ||
|
|
e006de5d4b | ||
|
|
94e2cac03b | ||
|
|
b27029f674 | ||
|
|
14aa22198f | ||
|
|
cf7b489fec | ||
|
|
8483948f23 |
@@ -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 ./...
|
||||||
|
|
||||||
|
|||||||
@@ -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 ./...
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -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)",
|
||||||
|
|||||||
+3
-3
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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();
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user