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>
264 lines
8.9 KiB
Go
264 lines
8.9 KiB
Go
// 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
|
||
}
|