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" ) // 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. func Passes(p dbq.ReleaseTypeProfile, primary string, secondary []string) bool { if !containsFold(p.PrimaryTypes, primary) { return false } for _, s := range secondary { if !containsFold(p.SecondaryTypes, s) { return false } } 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) }