Files
minstrel/internal/requests/service.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00

264 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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: 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 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
}