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>
This commit is contained in:
2026-08-01 22:51:51 -04:00
co-authored by Claude Opus 5
parent e006de5d4b
commit 86af79bd2f
13 changed files with 619 additions and 4 deletions
+8
View File
@@ -518,6 +518,14 @@ type SmtpConfig struct {
UpdatedAt pgtype.Timestamptz
}
type SuggestionSnooze struct {
UserID pgtype.UUID
CandidateMbid string
CandidateName string
SnoozedUntil pgtype.Timestamptz
CreatedAt pgtype.Timestamptz
}
type SystemPlaylistRotationState struct {
UserID pgtype.UUID
PlaylistKind string
+15 -2
View File
@@ -1082,6 +1082,18 @@ contributions AS (
AND r.lidarr_artist_mbid = u.candidate_mbid
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,
candidate_name,
@@ -1127,8 +1139,9 @@ type SuggestArtistsForUserRow struct {
// 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, or already requested and not terminal,
// are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
// 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) {
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
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
}