Files
bvandeusenandClaude Opus 5.5 ca57133c8a
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
fix: a release group whose title names a live, remix or demo version is wanted only when the profile allows that version (M502 #5573)
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>
2026-10-10 18:39:35 -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 (#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)
}