release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m51s
release / android (push) Successful in 5m18s
release / integration (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 21s
release / Verify release artifacts (tag releases only) (push) Skipped
MusicBrainz types My Chemical Romance's "Common People (Recorded For BBC Radio 1)" as a plain Single, so a studio-only follow wanted it. Passes now reads the title's qualifier (brackets, or after a dash) for the version it names and treats it as a secondary type; a version no type names, an acoustic or instrumental one, no profile passes. The follow check takes off the list an open follow want the rule now refuses. Judge counts bbc, sessions and unplugged as live. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
6.6 KiB
Go
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 (#5573): "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)
|
|
}
|