Files
minstrel/internal/wanted/profiles.go
T
bvandeusenandClaude Opus 5.5 fcf5b7e5a8 docs: wanted, dbtest, recommendation and playlists comments say what the code does (#6277)
The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.

Checked: non-comment tokens identical to HEAD; gofmt clean.

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

229 lines
6.6 KiB
Go

package wanted
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// PrimaryTypes and SecondaryTypes are MusicBrainz's release-group types, in
// the order the profile editor lists them.
var (
PrimaryTypes = []string{"Album", "EP", "Single", "Broadcast", "Other"}
SecondaryTypes = []string{
"Compilation", "Live", "Remix", "Soundtrack", "DJ-mix", "Mixtape/Street",
"Demo", "Spokenword", "Interview", "Audiobook", "Audio drama", "Field recording",
}
)
// Profile errors, which the API answers as 400 or 409.
var (
ErrProfileInvalid = errors.New("wanted: profile is not valid")
ErrProfileNotFound = errors.New("wanted: no such profile")
ErrProfileInUse = errors.New("wanted: profile is in use")
ErrProfileDefault = errors.New("wanted: the default profile cannot be deleted")
ErrProfileNameUsed = errors.New("wanted: a profile already has that name")
)
// Passes reports whether a release group of the given types is one a follow
// on profile p wants: its primary type is listed, and every secondary type
// it carries is allowed. A group with no secondary type is a studio release,
// which every profile passes on its primary type alone.
//
// A version its title names counts as a secondary type MusicBrainz left
// off: "Common People (Recorded For BBC Radio 1)" is live whatever
// its types say, and a version no type names, an acoustic one, no profile
// passes.
func Passes(p dbq.ReleaseTypeProfile, title, primary string, secondary []string) bool {
if !containsFold(p.PrimaryTypes, primary) {
return false
}
for _, s := range secondary {
if !containsFold(p.SecondaryTypes, s) {
return false
}
}
if v, ok := release.VersionInTitle(title); ok {
return v.Typ != "" && containsFold(p.SecondaryTypes, v.Typ)
}
return true
}
func containsFold(list []string, s string) bool {
return slices.ContainsFunc(list, func(v string) bool { return strings.EqualFold(v, s) })
}
// ProfileInput is a profile as the editor sends it.
type ProfileInput struct {
Name string
PrimaryTypes []string
SecondaryTypes []string
IsDefault bool
}
// normalize checks the input against MusicBrainz's types and returns it with
// each type in MusicBrainz's spelling and the editor's order.
func (in ProfileInput) normalize() (ProfileInput, error) {
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || len(in.Name) > 60 {
return in, fmt.Errorf("%w: a name of 1 to 60 characters is required", ErrProfileInvalid)
}
primary, err := pick(PrimaryTypes, in.PrimaryTypes)
if err != nil {
return in, err
}
if len(primary) == 0 {
return in, fmt.Errorf("%w: choose at least one kind of release", ErrProfileInvalid)
}
secondary, err := pick(SecondaryTypes, in.SecondaryTypes)
if err != nil {
return in, err
}
in.PrimaryTypes, in.SecondaryTypes = primary, secondary
return in, nil
}
func pick(known, chosen []string) ([]string, error) {
for _, c := range chosen {
if !containsFold(known, c) {
return nil, fmt.Errorf("%w: %q is not a MusicBrainz release type", ErrProfileInvalid, c)
}
}
out := []string{}
for _, k := range known {
if containsFold(chosen, k) {
out = append(out, k)
}
}
return out, nil
}
// CreateProfile adds a profile, making it the default when asked.
func (s *Service) CreateProfile(ctx context.Context, in ProfileInput) (dbq.ReleaseTypeProfile, error) {
in, err := in.normalize()
if err != nil {
return dbq.ReleaseTypeProfile{}, err
}
var out dbq.ReleaseTypeProfile
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
p, err := q.CreateReleaseTypeProfile(ctx, dbq.CreateReleaseTypeProfileParams{
Name: in.Name, PrimaryTypes: in.PrimaryTypes, SecondaryTypes: in.SecondaryTypes,
})
if err != nil {
return nameErr(err)
}
if in.IsDefault {
if err := makeDefault(ctx, q, p.ID); err != nil {
return err
}
p.IsDefault = true
}
out = p
return nil
})
return out, err
}
// UpdateProfile replaces a profile's name and types. IsDefault only ever
// moves the default here; to unset it, make another profile the default.
func (s *Service) UpdateProfile(ctx context.Context, id pgtype.UUID, in ProfileInput) (dbq.ReleaseTypeProfile, error) {
in, err := in.normalize()
if err != nil {
return dbq.ReleaseTypeProfile{}, err
}
var out dbq.ReleaseTypeProfile
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
p, err := q.UpdateReleaseTypeProfile(ctx, dbq.UpdateReleaseTypeProfileParams{
ID: id, Name: in.Name, PrimaryTypes: in.PrimaryTypes, SecondaryTypes: in.SecondaryTypes,
})
if errors.Is(err, pgx.ErrNoRows) {
return ErrProfileNotFound
}
if err != nil {
return nameErr(err)
}
if in.IsDefault && !p.IsDefault {
if err := makeDefault(ctx, q, p.ID); err != nil {
return err
}
p.IsDefault = true
}
out = p
return nil
})
return out, err
}
func makeDefault(ctx context.Context, q *dbq.Queries, id pgtype.UUID) error {
if err := q.ClearDefaultReleaseTypeProfile(ctx); err != nil {
return fmt.Errorf("wanted: clear default: %w", err)
}
if _, err := q.SetDefaultReleaseTypeProfile(ctx, id); err != nil {
return fmt.Errorf("wanted: set default: %w", err)
}
return nil
}
// DeleteProfile removes a profile no follow uses. The default stays.
func (s *Service) DeleteProfile(ctx context.Context, id pgtype.UUID) error {
q := dbq.New(s.pool)
p, err := q.GetReleaseTypeProfile(ctx, id)
if errors.Is(err, pgx.ErrNoRows) {
return ErrProfileNotFound
}
if err != nil {
return fmt.Errorf("wanted: read profile: %w", err)
}
if p.IsDefault {
return ErrProfileDefault
}
if _, err := q.DeleteReleaseTypeProfile(ctx, id); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
return ErrProfileInUse
}
return fmt.Errorf("wanted: delete profile: %w", err)
}
return nil
}
// profileOrDefault reads the profile a follow names; the zero id means the
// default.
func profileOrDefault(ctx context.Context, q *dbq.Queries, id pgtype.UUID) (dbq.ReleaseTypeProfile, error) {
var (
p dbq.ReleaseTypeProfile
err error
)
if id.Valid {
p, err = q.GetReleaseTypeProfile(ctx, id)
} else {
p, err = q.GetDefaultReleaseTypeProfile(ctx)
}
if errors.Is(err, pgx.ErrNoRows) {
return p, ErrProfileNotFound
}
if err != nil {
return p, fmt.Errorf("wanted: read profile: %w", err)
}
return p, nil
}
func nameErr(err error) error {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
return ErrProfileNameUsed
}
return fmt.Errorf("wanted: save profile: %w", err)
}