feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 00:46:22 -04:00
co-authored by Claude Opus 5.5
parent 867f52358c
commit 04515a977c
93 changed files with 6522 additions and 4484 deletions
+198
View File
@@ -0,0 +1,198 @@
package requests
import (
"context"
"errors"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Reconciler is a background worker that completes approved requests once
// the library holds what they asked for: an artist request when the artist
// is in the library, an album or track request when the want it recorded is
// held (internal/wanted settles wants). Errors per row are logged at WARN and
// do not abort the tick.
type Reconciler struct {
pool *pgxpool.Pool
logger *slog.Logger
bus *eventbus.Bus
notifier *notifications.Notifier // nil: no inbox notifications
tick time.Duration
batch int32
}
// NewReconciler constructs a Reconciler with production defaults: 5-minute
// tick, batch size 50. Pass nil for bus when no SSE publishing is desired.
func NewReconciler(pool *pgxpool.Pool, logger *slog.Logger, bus *eventbus.Bus) *Reconciler {
return &Reconciler{pool: pool, logger: logger, bus: bus, tick: 5 * time.Minute, batch: 50}
}
// SetNotifier makes completions land in the requester's notifications inbox
// (M489). Without one, completions are only broadcast on the bus.
func (r *Reconciler) SetNotifier(n *notifications.Notifier) { r.notifier = n }
// publishCompleted tells the requester their request arrived: a
// request.status_changed event for open screens, and a notification that
// outlives the connection.
func (r *Reconciler) publishCompleted(ctx context.Context, row dbq.Request) {
p := notifications.Payload{
RequestID: formatUUIDForBus(row.ID),
RequestKind: string(row.Kind),
Name: DisplayName(row),
Artist: row.ArtistName,
Title: requestedTitle(row),
}
if row.MatchedAlbumID.Valid {
p.AlbumID = formatUUIDForBus(row.MatchedAlbumID)
} else if row.MatchedArtistID.Valid {
p.ArtistID = formatUUIDForBus(row.MatchedArtistID)
}
r.notifier.NotifyLogged(ctx, notifications.KindRequestCompleted, notifications.ToUser(row.UserID), p.Map())
if r.bus == nil {
return
}
r.bus.Publish(eventbus.Event{
Kind: "request.status_changed",
UserID: formatUUIDForBus(row.UserID),
Data: map[string]any{
"request_id": formatUUIDForBus(row.ID),
"status": "completed",
"kind": string(row.Kind),
},
})
}
// formatUUIDForBus renders a pgtype.UUID as the canonical 8-4-4-4-12 hex
// string. Mirrors helpers in internal/api/convert.go and other packages;
// inlined here to avoid a back-edge dependency from requests onto
// the api layer.
func formatUUIDForBus(u pgtype.UUID) string {
if !u.Valid {
return ""
}
const hex = "0123456789abcdef"
out := make([]byte, 36)
pos := 0
for i, b := range u.Bytes {
if i == 4 || i == 6 || i == 8 || i == 10 {
out[pos] = '-'
pos++
}
out[pos] = hex[b>>4]
out[pos+1] = hex[b&0xf]
pos += 2
}
return string(out)
}
// Run blocks until ctx is cancelled, ticking every r.tick. Errors from
// tickOnce are logged at WARN and never propagated.
func (r *Reconciler) Run(ctx context.Context) {
t := time.NewTicker(r.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := r.tickOnce(ctx); err != nil {
r.logger.Warn("requests: reconciler tick failed", "err", err)
}
}
}
}
// tickOnce loads approved requests (oldest first, up to r.batch) and
// completes those the library now holds.
func (r *Reconciler) tickOnce(ctx context.Context) error {
q := dbq.New(r.pool)
rows, err := q.ListApprovedRequestsForReconcile(ctx, r.batch)
if err != nil {
return err
}
for _, row := range rows {
if err := r.reconcileRow(ctx, q, row); err != nil {
r.logger.Warn("requests: reconciler row failed",
"request_id", row.ID, "kind", row.Kind, "err", err)
}
}
return nil
}
// reconcileRow completes one request if the library holds it. A request
// not yet held is not an error.
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, row dbq.Request) error {
if row.Kind == dbq.RequestKindArtist {
return r.reconcileArtist(ctx, q, row)
}
return r.reconcileAlbum(ctx, q, row)
}
func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.Request) error {
var artistID pgtype.UUID
err := r.pool.QueryRow(ctx, "SELECT id FROM artists WHERE mbid = $1", row.ArtistMbid).Scan(&artistID)
if errors.Is(err, pgx.ErrNoRows) {
return nil // not in library yet
}
if err != nil {
return err
}
completed, err := q.CompleteRequest(ctx, dbq.CompleteRequestParams{ID: row.ID, MatchedArtistID: artistID})
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
// reconcileAlbum completes an album or track request once its want is held,
// matched to the album that answered it. A track request is matched to the
// track it named when that recording arrived, else the newest arrival.
func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.Request) error {
if row.AlbumMbid == nil || *row.AlbumMbid == "" {
return nil
}
w, err := q.GetWantByReleaseGroup(ctx, *row.AlbumMbid)
if errors.Is(err, pgx.ErrNoRows) {
// Removed from the wanted list while the request waited. Wanting it
// again keeps the approval meaningful; the admin who removed it can
// reject a request instead.
_, err = wanted.WantWith(ctx, q, wantFor(row))
return err
}
if err != nil {
return err
}
if !w.FulfilledAt.Valid || !w.AlbumID.Valid {
return nil
}
params := dbq.CompleteRequestParams{ID: row.ID, MatchedAlbumID: w.AlbumID}
if row.Kind == dbq.RequestKindTrack {
var trackID pgtype.UUID
err := r.pool.QueryRow(ctx, `
SELECT id FROM tracks
WHERE album_id = $1 AND missing_since IS NULL
ORDER BY (mbid IS NOT DISTINCT FROM $2) DESC, added_at DESC, id
LIMIT 1`, w.AlbumID, row.TrackMbid).Scan(&trackID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return err
}
params.MatchedTrackID = trackID
}
completed, err := q.CompleteRequest(ctx, params)
if err != nil {
return err
}
r.publishCompleted(ctx, completed)
return nil
}
+310
View File
@@ -0,0 +1,310 @@
package requests
import (
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiTokenHash: "x-" + name,
})
if err != nil {
t.Fatalf("seed user: %v", err)
}
return u.ID
}
func seedAlbum(t *testing.T, q *dbq.Queries, artistName, artistMBID, title, group string) (dbq.Artist, dbq.Album) {
t.Helper()
ctx := context.Background()
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: artistName, SortName: artistName, Mbid: &artistMBID})
if err != nil {
t.Fatal(err)
}
release := group + "-release"
album, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID, Mbid: &release})
if err != nil {
t.Fatal(err)
}
if err := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{ID: album.ID, ReleaseGroupMbid: &group}); err != nil {
t.Fatal(err)
}
return artist, album
}
func seedTrack(t *testing.T, q *dbq.Queries, album dbq.Album, artist dbq.Artist, title, path string) dbq.Track {
t.Helper()
tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title, AlbumID: album.ID, ArtistID: artist.ID,
DurationMs: 180000, FilePath: path, FileSize: 1024, FileFormat: "flac",
})
if err != nil {
t.Fatal(err)
}
return tr
}
func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func TestCreate_ValidatesEachKind(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "creator")
svc := NewService(pool)
ctx := context.Background()
if r, err := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-mbid", ArtistName: "X"}); err != nil ||
r.Status != dbq.RequestStatusPending {
t.Fatalf("artist: %+v, %v", r, err)
}
for name, p := range map[string]CreateParams{
"album without its group": {Kind: "album", ArtistMBID: "a", ArtistName: "X"},
"track without its album": {Kind: "track", ArtistMBID: "a", ArtistName: "X", TrackMBID: "t", TrackTitle: "Y"},
"unknown kind": {Kind: "playlist", ArtistMBID: "a", ArtistName: "X"},
} {
if _, err := svc.Create(ctx, user, p); !errors.Is(err, ErrInvalidKindFields) {
t.Errorf("%s: err = %v, want ErrInvalidKindFields", name, err)
}
}
again, created, err := svc.CreateTracked(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-mbid", ArtistName: "X"})
if err != nil || created || again.Kind != dbq.RequestKindArtist {
t.Errorf("a repeat request dedupes: created=%v, %v", created, err)
}
}
func TestApprove_AlbumAndTrackRequestsWantTheirReleaseGroup(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "wanter")
admin := seedUser(t, pool, "approver")
svc := NewService(pool)
ctx := context.Background()
album, _ := svc.Create(ctx, user, CreateParams{
Kind: "album", ArtistMBID: "artist-1", ArtistName: "Gorillaz", AlbumMBID: "group-demon-days", AlbumTitle: "Demon Days",
})
track, _ := svc.Create(ctx, user, CreateParams{
Kind: "track", ArtistMBID: "artist-1", ArtistName: "Gorillaz", AlbumMBID: "group-humanz", AlbumTitle: "Humanz",
TrackMBID: "rec-1", TrackTitle: "Andromeda",
})
for _, r := range []dbq.Request{album, track} {
got, err := svc.Approve(ctx, r.ID, admin, pgtype.UUID{})
if err != nil || got.Status != dbq.RequestStatusApproved || got.DecidedBy != admin {
t.Fatalf("approve %s: %+v, %v", r.Kind, got, err)
}
}
for group, title := range map[string]string{"group-demon-days": "Demon Days", "group-humanz": "Humanz"} {
w, err := q.GetWantByReleaseGroup(ctx, group)
if err != nil {
t.Fatalf("want %s: %v", group, err)
}
if w.Title != title || w.Reason != "request" || w.CreatedBy != user || w.FulfilledAt.Valid {
t.Errorf("want %s = %+v: the requester's open want", group, w)
}
}
if _, err := svc.Approve(ctx, album.ID, admin, pgtype.UUID{}); !errors.Is(err, ErrNotPending) {
t.Errorf("approving twice: err = %v, want ErrNotPending", err)
}
if _, err := svc.Approve(ctx, pgtype.UUID{Bytes: [16]byte{1}, Valid: true}, admin, pgtype.UUID{}); !errors.Is(err, ErrNotFound) {
t.Errorf("no such request: err = %v", err)
}
}
func TestApprove_ArtistRequestFollowsAndWantsWhatExists(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "follower")
ctx := context.Background()
svc := NewService(pool)
albumsOnly, err := q.CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: "Albums only", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{},
})
if err != nil {
t.Fatal(err)
}
r, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-2", ArtistName: "Moe Shop"})
if _, err := svc.Approve(ctx, r.ID, user, albumsOnly.ID); err != nil {
t.Fatal(err)
}
f, err := q.GetFollow(ctx, "artist-2")
if err != nil {
t.Fatal(err)
}
if f.ProfileID != albumsOnly.ID || !f.WantExisting || f.BaselineAt.Valid {
t.Errorf("follow = %+v: on the chosen profile, wanting what exists, baseline still to read", f)
}
r2, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-3", ArtistName: "Kero Kero Bonito"})
if _, err := svc.Approve(ctx, r2.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
if f, _ := q.GetFollow(ctx, "artist-3"); f.ProfileName != "Standard" {
t.Errorf("no profile chosen: on %q, want the default", f.ProfileName)
}
}
func TestRejectAndCancel(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "rejected")
svc := NewService(pool)
ctx := context.Background()
r, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-1", ArtistName: "X"})
if got, err := svc.Reject(ctx, r.ID, user, "low quality"); err != nil || got.Status != dbq.RequestStatusRejected {
t.Fatalf("reject: %+v, %v", got, err)
}
if _, err := svc.Reject(ctx, r.ID, user, "again"); !errors.Is(err, ErrNotPending) {
t.Errorf("reject twice: err = %v", err)
}
c, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "a-2", ArtistName: "Y"})
if _, err := svc.Cancel(ctx, c.ID, user); err != nil {
t.Fatal(err)
}
if _, err := svc.Cancel(ctx, c.ID, user); !errors.Is(err, ErrNotPending) {
t.Errorf("cancel twice: err = %v", err)
}
}
func TestReconciler_CompletesWhenTheWantIsHeld(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "reconciled")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{
Kind: "track", ArtistMBID: "artist-4", ArtistName: "Notify Artist", AlbumMBID: "group-notify", AlbumTitle: "Notify Album",
TrackMBID: "rec-wanted", TrackTitle: "Wanted Track",
})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
rec := NewReconciler(pool, quietLogger(), nil)
rec.SetNotifier(notifications.New(pool, nil, nil))
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusApproved {
t.Fatalf("status %v before the want is held, want approved", got.Status)
}
artist, album := seedAlbum(t, q, "Notify Artist", "artist-4", "Notify Album", "group-notify")
_ = seedTrack(t, q, album, artist, "Other Track", "/music/notify/01.flac")
wantedTrack := seedTrack(t, q, album, artist, "Wanted Track", "/music/notify/02.flac")
if _, err := pool.Exec(ctx, "UPDATE tracks SET mbid = 'rec-wanted', added_at = added_at - interval '1 hour' WHERE id = $1", wantedTrack.ID); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "UPDATE wants SET fulfilled_at = now(), album_id = $1 WHERE release_group_mbid = 'group-notify'", album.ID); err != nil {
t.Fatal(err)
}
for range 2 { // the second tick finds nothing left to complete
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
}
got, _ := q.GetRequestByID(ctx, req.ID)
if got.Status != dbq.RequestStatusCompleted || got.MatchedAlbumID != album.ID || got.MatchedTrackID != wantedTrack.ID {
t.Errorf("request = %+v: completed, matched to the album and the track it named", got)
}
inbox, err := q.ListNotifications(ctx, dbq.ListNotificationsParams{UserID: user, PageLimit: 10})
if err != nil {
t.Fatal(err)
}
if len(inbox) != 1 || inbox[0].Kind != string(notifications.KindRequestCompleted) {
t.Fatalf("inbox = %+v, want one request_completed", inbox)
}
var p notifications.Payload
if err := json.Unmarshal(inbox[0].Payload, &p); err != nil {
t.Fatal(err)
}
if p.Name != "Notify Artist – Wanted Track" || p.AlbumID != formatUUIDForBus(album.ID) {
t.Errorf("payload = %+v", p)
}
}
func TestReconciler_ArtistRequestCompletesOnceTheArtistIsHere(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "artistreq")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{Kind: "artist", ArtistMBID: "artist-5", ArtistName: "Arrives"})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
rec := NewReconciler(pool, quietLogger(), nil)
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusApproved {
t.Fatalf("status %v with nothing here", got.Status)
}
artist, _ := seedAlbum(t, q, "Arrives", "artist-5", "First", "group-first")
if err := rec.tickOnce(ctx); err != nil {
t.Fatal(err)
}
if got, _ := q.GetRequestByID(ctx, req.ID); got.Status != dbq.RequestStatusCompleted || got.MatchedArtistID != artist.ID {
t.Errorf("request = %+v", got)
}
}
func TestReconciler_RewantsWhatWasRemovedWhileTheRequestWaited(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
user := seedUser(t, pool, "rewant")
svc := NewService(pool)
ctx := context.Background()
req, _ := svc.Create(ctx, user, CreateParams{
Kind: "album", ArtistMBID: "artist-6", ArtistName: "Gone", AlbumMBID: "group-gone", AlbumTitle: "Gone",
})
if _, err := svc.Approve(ctx, req.ID, user, pgtype.UUID{}); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, "DELETE FROM wants"); err != nil {
t.Fatal(err)
}
if err := NewReconciler(pool, quietLogger(), nil).tickOnce(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, "group-gone"); err != nil {
t.Errorf("an approved request's want should be recorded again: %v", err)
}
}
+263
View File
@@ -0,0 +1,263 @@
// Package requests owns the lifecycle of what users ask to have added. The
// synchronous Service handles Create/List/Approve/Reject/Cancel; approving
// records the request on the wanted list (M502 #5520): an album or a track
// wants its album's release group, an artist is followed. The async
// Reconciler (separate file) completes approved requests once the library
// holds what they asked for.
package requests
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
)
// Public errors. Handlers map these to API error codes.
var (
ErrInvalidKindFields = errors.New("requests: missing required fields for kind")
ErrNotPending = errors.New("requests: request is not pending")
// ErrNotFound aliases apierror.ErrNotFound so handlers can errors.Is
// against the shared sentinel.
ErrNotFound = apierror.ErrNotFound
)
// CreateParams is the input for a new request from a user. AlbumMBID is the
// album's MusicBrainz release group.
type CreateParams struct {
Kind string // "artist", "album", or "track"
ArtistMBID string
AlbumMBID string // required for kind=album/track
TrackMBID string // required for kind=track
ArtistName string
AlbumTitle string // required for kind=album/track
TrackTitle string // required for kind=track
}
// Service owns the request lifecycle.
type Service struct {
pool *pgxpool.Pool
}
// NewService constructs a Service.
func NewService(pool *pgxpool.Pool) *Service {
return &Service{pool: pool}
}
// Create validates the kind→required-fields invariant and inserts a
// pending row.
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, error) {
row, _, err := s.CreateTracked(ctx, userID, p)
return row, err
}
// CreateTracked is Create, also reporting whether a row was inserted. A
// request that dedupes into one already in flight is not new, so callers
// that announce new requests (M489) say nothing for it.
func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, bool, error) {
if err := validateKindFields(p); err != nil {
return dbq.Request{}, false, err
}
q := dbq.New(s.pool)
// Dedup: if a non-terminal request for this MBID already exists,
// return it instead of inserting a duplicate. The Discover/search UI
// already hides requested candidates; this backstops races and direct
// API callers. Match on the new request's own-kind MBID.
dedupMBID := p.ArtistMBID
switch p.Kind {
case "album":
dedupMBID = p.AlbumMBID
case "track":
dedupMBID = p.TrackMBID
}
if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil {
return existing, false, nil
} else if !errors.Is(derr, pgx.ErrNoRows) {
return dbq.Request{}, false, fmt.Errorf("create: dedup check: %w", derr)
}
row, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
UserID: userID,
Kind: dbq.RequestKind(p.Kind),
ArtistMbid: p.ArtistMBID,
AlbumMbid: strPtr(p.AlbumMBID),
TrackMbid: strPtr(p.TrackMBID),
ArtistName: p.ArtistName,
AlbumTitle: strPtr(p.AlbumTitle),
TrackTitle: strPtr(p.TrackTitle),
})
if err != nil {
return dbq.Request{}, false, fmt.Errorf("create: %w", err)
}
return row, true, nil
}
// DisplayName is a request as a person would say it: "Moe Shop" for an
// artist, "Moe Shop – WWW" for an album or a track.
func DisplayName(row dbq.Request) string {
if t := requestedTitle(row); t != "" {
return row.ArtistName + " – " + t
}
return row.ArtistName
}
// requestedTitle is the album or track a request names, or "" for an
// artist request. DisplayName is the artist and this, joined.
func requestedTitle(row dbq.Request) string {
switch {
case row.Kind == dbq.RequestKindTrack && row.TrackTitle != nil:
return *row.TrackTitle
case row.Kind == dbq.RequestKindAlbum && row.AlbumTitle != nil:
return *row.AlbumTitle
}
return ""
}
func validateKindFields(p CreateParams) error {
if p.ArtistMBID == "" || p.ArtistName == "" {
return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields)
}
switch p.Kind {
case "artist":
// fine
case "album":
if p.AlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields)
}
case "track":
if p.AlbumMBID == "" || p.AlbumTitle == "" {
return fmt.Errorf("%w: track kind requires album_mbid and album_title (its album is what is wanted)", ErrInvalidKindFields)
}
if p.TrackMBID == "" || p.TrackTitle == "" {
return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields)
}
default:
return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind)
}
return nil
}
// ListByStatus returns requests with the given status, ordered by requested_at DESC.
func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.Request, error) {
return dbq.New(s.pool).ListRequestsByStatus(ctx, dbq.ListRequestsByStatusParams{
Status: dbq.RequestStatus(status), Limit: limit,
})
}
// ListForUser returns all requests by a user, ordered by requested_at DESC.
func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.Request, error) {
return dbq.New(s.pool).ListRequestsForUser(ctx, dbq.ListRequestsForUserParams{
UserID: userID, Limit: limit,
})
}
// Approve puts a pending request on the wanted list and marks it approved,
// in one transaction. An album or track request wants the album's release
// group, recorded as the requester's. An artist request follows the artist
// on profileID (the zero id means the default profile) and also wants what
// they have already released that the profile passes: someone asking for an
// artist wants their music, not only what comes next.
func (s *Service) Approve(ctx context.Context, requestID, approverID, profileID pgtype.UUID) (dbq.Request, error) {
var approved dbq.Request
err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
row, err := q.GetRequestByID(ctx, requestID)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("approve: get: %w", err)
}
if row.Status != dbq.RequestStatusPending {
return ErrNotPending
}
switch row.Kind {
case dbq.RequestKindArtist:
if _, err := wanted.FollowWith(ctx, q, wanted.FollowParams{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
ProfileID: profileID, By: approverID, WantExisting: true,
}); err != nil {
return err
}
default:
if _, err := wanted.WantWith(ctx, q, wantFor(row)); err != nil {
return err
}
}
approved, err = q.ApproveRequest(ctx, dbq.ApproveRequestParams{ID: requestID, DecidedBy: approverID})
if err != nil {
return fmt.Errorf("approve: persist: %w", err)
}
return nil
})
return approved, err
}
// wantFor is the want an approved album or track request records.
func wantFor(row dbq.Request) wanted.WantParams {
p := wanted.WantParams{
ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName,
Reason: wanted.ReasonRequest, CreatedBy: row.UserID,
}
if row.AlbumMbid != nil {
p.GroupMBID = *row.AlbumMbid
}
if row.AlbumTitle != nil {
p.Title = *row.AlbumTitle
}
return p
}
// Reject transitions a pending request to rejected, recording notes and
// who decided. Returns ErrNotPending if the request is not pending,
// ErrNotFound if no such request exists.
func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.Request, error) {
row, err := dbq.New(s.pool).RejectRequest(ctx, dbq.RejectRequestParams{
ID: requestID, Notes: strPtr(notes), DecidedBy: adminID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
// Either not found OR not pending — check which.
cur, gerr := dbq.New(s.pool).GetRequestByID(ctx, requestID)
if gerr != nil {
return dbq.Request{}, ErrNotFound
}
if cur.Status != dbq.RequestStatusPending {
return dbq.Request{}, ErrNotPending
}
return dbq.Request{}, ErrNotFound
}
return dbq.Request{}, fmt.Errorf("reject: %w", err)
}
return row, nil
}
// Cancel lets a user withdraw their own pending request. The SQL WHERE
// clause enforces both ownership (user_id = $2) and pending status
// (status = 'pending'). A zero-rows result always means ErrNotPending
// from the caller's perspective.
func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.Request, error) {
row, err := dbq.New(s.pool).CancelRequest(ctx, dbq.CancelRequestParams{
ID: requestID, DecidedBy: userID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return dbq.Request{}, ErrNotPending
}
return dbq.Request{}, fmt.Errorf("cancel: %w", err)
}
return row, nil
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}