feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz release group. A want comes from an approved request, a new release by a followed artist, or an album whose files went missing, and is held once the library has a full edition of it. - Migration 0081: release_type_profiles (Standard seeded: Album, EP, Single, studio only), follows, follow_seen_groups and wants. lidarr_requests becomes requests with plain *_mbid columns; approved album and track requests move onto the wanted list, approved artist requests become follows, and the library's album artists start out followed. missing_reacquisitions and the retry settings go. - internal/wanted: follows are checked against MusicBrainz discographies daily. The first check sets a baseline, and only releases after it are wanted unless the follow asked for what already exists. Profiles filter by primary and secondary type, so live recordings stay out unless a profile ticks Live. - internal/requests (was lidarrrequests): approving puts the request on the wanted list, and an artist request can be approved on a chosen profile. A request completes when its want is held. - The re-acquisition sweeper wants a missing album back instead of asking Lidarr; searching for it is the search schedule's (#5521). - Web: an admin Wanted page (Wanted, Missing files, Held, Following), a follow control on artist pages, a release-type profiles card on Integrations, "Approve on a profile" on Requests, and want state on Missing files. The artist detail API returns the artist's MBID. - Android: the request body sends artist_mbid, album_mbid and track_mbid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user