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

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:
2026-10-10 00:46:22 -04:00
co-authored by Claude Opus 5.5
parent 867f52358c
commit 04515a977c
93 changed files with 6522 additions and 4484 deletions
+219
View File
@@ -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)
}
+494
View File
@@ -0,0 +1,494 @@
// Package wanted is what Minstrel is looking for (M502 #5520): followed
// artists, each on a release-type profile, and wanted release groups. A want
// comes from an approved request, a followed artist's new release, or an
// album whose files went missing; it is held once the library has a full
// copy. Searching for open wants is #5521's.
package wanted
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// Reasons a release group is wanted, matching wants_reason_check.
const (
ReasonRequest = "request"
ReasonFollow = "follow"
ReasonMissing = "missing"
)
// variousArtists is MusicBrainz's Various Artists. Its "discography" is every
// compilation ever catalogued; nobody follows it.
const variousArtists = "89ad4ac3-39f7-470e-963a-56509c546377"
// ErrNotFollowable: an artist nobody follows (Various Artists, or one of
// MusicBrainz's bracketed placeholders such as [unknown]).
var ErrNotFollowable = errors.New("wanted: not an artist anyone follows")
// Service owns follows, wants and the worker that keeps them current.
type Service struct {
pool *pgxpool.Pool
cat *catalogue.Service
logger *slog.Logger
now func() time.Time
// releaseGroup names the group of a MusicBrainz release id, to move a
// want recorded under a release id (#5241) to its group. A field so tests
// stand in for MusicBrainz.
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
}
// New makes a Service reading discographies and editions through cat.
func New(pool *pgxpool.Pool, cat *catalogue.Service, logger *slog.Logger) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{pool: pool, cat: cat, logger: logger, now: time.Now, releaseGroup: tags.ReleaseGroupForRelease}
}
// Followable reports whether an artist is one anyone would follow.
func Followable(mbid, name string) bool {
if mbid == "" || mbid == variousArtists {
return false
}
return len(name) < 2 || name[0] != '[' || name[len(name)-1] != ']'
}
// FollowParams is a follow as the caller asks for it. A zero ProfileID means
// the default profile. WantExisting also wants what the artist has already
// released that the profile passes and the library lacks.
type FollowParams struct {
ArtistMBID string
ArtistName string
ProfileID pgtype.UUID
By pgtype.UUID
WantExisting bool
}
// Follow starts or changes a follow. The worker reads the discography.
func (s *Service) Follow(ctx context.Context, p FollowParams) (dbq.Follow, error) {
return FollowWith(ctx, dbq.New(s.pool), p)
}
// FollowWith is Follow inside the caller's transaction (request approval).
func FollowWith(ctx context.Context, q *dbq.Queries, p FollowParams) (dbq.Follow, error) {
if !Followable(p.ArtistMBID, p.ArtistName) {
return dbq.Follow{}, ErrNotFollowable
}
profile, err := profileOrDefault(ctx, q, p.ProfileID)
if err != nil {
return dbq.Follow{}, err
}
f, err := q.UpsertFollow(ctx, dbq.UpsertFollowParams{
ArtistMbid: p.ArtistMBID, ArtistName: p.ArtistName, ProfileID: profile.ID,
FollowedBy: p.By, WantExisting: p.WantExisting,
})
if err != nil {
return dbq.Follow{}, fmt.Errorf("wanted: follow: %w", err)
}
return f, nil
}
// Unfollow stops following. Wants already recorded stay.
func (s *Service) Unfollow(ctx context.Context, artistMBID string) (bool, error) {
n, err := dbq.New(s.pool).DeleteFollow(ctx, artistMBID)
if err != nil {
return false, fmt.Errorf("wanted: unfollow: %w", err)
}
return n > 0, nil
}
// WantParams is a release group to want.
type WantParams struct {
GroupMBID string
ArtistMBID string
ArtistName string
Title string
PrimaryType string
FirstReleaseDate string
Reason string
CreatedBy pgtype.UUID
}
// WantWith records a want inside the caller's transaction, or returns the
// want already recorded for the group.
func WantWith(ctx context.Context, q *dbq.Queries, p WantParams) (dbq.Want, error) {
if p.GroupMBID == "" || p.ArtistMBID == "" {
return dbq.Want{}, errors.New("wanted: a release group and its artist are required")
}
w, err := q.WantReleaseGroup(ctx, dbq.WantReleaseGroupParams{
ReleaseGroupMbid: p.GroupMBID, ArtistMbid: p.ArtistMBID, ArtistName: p.ArtistName,
Title: p.Title, PrimaryType: p.PrimaryType, FirstReleaseDate: p.FirstReleaseDate,
Reason: p.Reason, CreatedBy: p.CreatedBy,
})
if err != nil {
return dbq.Want{}, fmt.Errorf("wanted: want: %w", err)
}
return w, nil
}
// RemoveWant drops a want, open or held.
func (s *Service) RemoveWant(ctx context.Context, id pgtype.UUID) (bool, error) {
n, err := dbq.New(s.pool).DeleteWant(ctx, id)
if err != nil {
return false, fmt.Errorf("wanted: remove want: %w", err)
}
return n > 0, nil
}
const (
// FollowRefreshAge is how often a followed artist's discography is read
// for new releases: daily, as Lidarr refreshed its artists.
FollowRefreshAge = 24 * time.Hour
// NewReleaseGrace: a group first seen after the baseline is wanted only
// if it came out no earlier than this before the follow began. MusicBrainz
// gains old releases all the time; a single from 1998 catalogued today is
// not a new release.
NewReleaseGrace = 30 * 24 * time.Hour
// Interval is how often the worker looks for work; followsPerPass and
// wantsPerPass bound one pass so neither starves the other.
Interval = 5 * time.Minute
followsPerPass = 60
wantsPerPass = 500
)
// Run applies follows and settles wants every Interval until ctx ends.
func (s *Service) Run(ctx context.Context) {
ticker := time.NewTicker(Interval)
defer ticker.Stop()
for {
res, err := s.Pass(ctx)
switch {
case err != nil && ctx.Err() == nil:
s.logger.Warn("wanted: pass stopped", "err", err, "follows", res.Follows, "wanted", res.Wanted, "held", res.Held)
case res.Wanted > 0 || res.Held > 0 || res.Baselined > 0:
s.logger.Info("wanted: pass complete", "follows", res.Follows, "baselined", res.Baselined,
"wanted", res.Wanted, "held", res.Held)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
// PassResult is what one pass did.
type PassResult struct {
Follows int // follows whose discography was applied
Baselined int // of those, first reads
Wanted int // wants recorded from follows
Held int // wants the library now answers
}
// Pass applies the follows due and settles open wants. An unreachable
// MusicBrainz stops the follows (they need it) but not the settling, which
// falls back to what is held.
func (s *Service) Pass(ctx context.Context) (PassResult, error) {
var res PassResult
ferr := s.applyFollows(ctx, &res)
if ctx.Err() != nil {
return res, ctx.Err()
}
if err := s.settleWants(ctx, &res); err != nil {
return res, err
}
return res, ferr
}
func (s *Service) applyFollows(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
due, err := q.ListFollowsDue(ctx, dbq.ListFollowsDueParams{
Before: pgtype.Timestamptz{Time: s.now().Add(-FollowRefreshAge), Valid: true},
MaxRows: followsPerPass,
})
if err != nil {
return fmt.Errorf("wanted: list follows due: %w", err)
}
for _, f := range due {
n, err := s.applyFollow(ctx, q, f)
if err != nil {
if errors.Is(err, musicbrainz.ErrUnreachable) || ctx.Err() != nil {
return err
}
s.logger.Warn("wanted: apply follow", "artist", f.ArtistName, "mbid", f.ArtistMbid, "err", err)
continue
}
res.Follows++
res.Wanted += n
if !f.BaselineAt.Valid {
res.Baselined++
}
}
return nil
}
// applyFollow reads the artist's discography and judges each release group
// the follow has not judged before. On the first read (the baseline) they
// are only marked seen, unless the follow asked to want what already exists.
// After it, a group is wanted when the profile passes it, it is recent, and
// the library does not hold it.
func (s *Service) applyFollow(ctx context.Context, q *dbq.Queries, f dbq.Follow) (int, error) {
d, err := s.cat.DiscographyWithin(ctx, f.ArtistMbid, FollowRefreshAge)
if err == nil && d.Stale {
// The held copy, served because MusicBrainz could not be asked:
// applying it would mark the follow checked without having looked.
return 0, musicbrainz.ErrUnreachable
}
if err != nil {
if !errors.Is(err, catalogue.ErrNotFound) {
return 0, err
}
// Merged away or removed from MusicBrainz: nothing to read. Marked
// checked so it is asked again tomorrow, not every pass.
s.logger.Info("wanted: followed artist not in MusicBrainz", "artist", f.ArtistName, "mbid", f.ArtistMbid)
return 0, q.MarkFollowChecked(ctx, f.ArtistMbid)
}
profile, err := q.GetReleaseTypeProfile(ctx, f.ProfileID)
if err != nil {
return 0, fmt.Errorf("read profile: %w", err)
}
groups, err := q.ListFollowGroups(ctx, f.ArtistMbid)
if err != nil {
return 0, fmt.Errorf("read discography: %w", err)
}
baseline := !f.BaselineAt.Valid
wanted := 0
for _, g := range groups {
judge := (baseline && f.WantExisting) || (!baseline && !g.Seen && s.recent(g.FirstReleaseDate, f.BaselineAt.Time))
if judge && Passes(profile, g.PrimaryType, g.SecondaryTypes) {
held, err := q.LibraryHoldsReleaseGroup(ctx, dbq.LibraryHoldsReleaseGroupParams{
GroupMbid: g.Mbid, Title: g.Title, ArtistMbid: f.ArtistMbid,
})
if err != nil {
return wanted, fmt.Errorf("library check: %w", err)
}
if !held {
w, err := WantWith(ctx, q, WantParams{
GroupMBID: g.Mbid, ArtistMBID: f.ArtistMbid, ArtistName: f.ArtistName, Title: g.Title,
PrimaryType: g.PrimaryType, FirstReleaseDate: g.FirstReleaseDate,
Reason: ReasonFollow, CreatedBy: f.FollowedBy,
})
if err != nil {
return wanted, err
}
if !w.FulfilledAt.Valid {
wanted++
}
}
}
if !g.Seen {
if err := q.MarkGroupSeen(ctx, dbq.MarkGroupSeenParams{ArtistMbid: f.ArtistMbid, ReleaseGroupMbid: g.Mbid}); err != nil {
return wanted, fmt.Errorf("mark seen: %w", err)
}
}
}
return wanted, q.MarkFollowChecked(ctx, f.ArtistMbid)
}
// recent reports whether a release dated date (MusicBrainz's partial date:
// "2026", "2026-10", "2026-10-09", or empty when not yet known) came out no
// earlier than NewReleaseGrace before the follow's baseline. A partial date
// is read as the last day it could mean, so a year-only date is not
// dismissed for being "January".
func (s *Service) recent(date string, baseline time.Time) bool {
t, ok := latestDate(date)
if !ok {
return true
}
return !t.Before(baseline.Add(-NewReleaseGrace))
}
func latestDate(date string) (time.Time, bool) {
if t, err := time.Parse("2006-01-02", date); err == nil {
return t, true
}
if t, err := time.Parse("2006-01", date); err == nil {
return t.AddDate(0, 1, -1), true
}
if t, err := time.Parse("2006", date); err == nil {
return t.AddDate(1, 0, -1), true
}
return time.Time{}, false
}
// settleWants marks open wants held when the library answers them. Each
// want's editions come from the catalogue, read from MusicBrainz the first
// time; once MusicBrainz is unreachable the rest use only what is held.
func (s *Service) settleWants(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
open, err := q.ListOpenWants(ctx, wantsPerPass)
if err != nil {
return fmt.Errorf("wanted: list open wants: %w", err)
}
mbDown := false
for _, w := range open {
if ctx.Err() != nil {
return ctx.Err()
}
shortest, moved, err := s.shortestEdition(ctx, q, w, &mbDown)
if err != nil {
s.logger.Warn("wanted: read editions", "want", w.Title, "group", w.ReleaseGroupMbid, "err", err)
}
if moved {
continue // settled next pass, under its group
}
album, ok, err := s.answeredBy(ctx, q, w, shortest)
if err != nil {
return err
}
if !ok {
continue
}
if err := q.FulfillWant(ctx, dbq.FulfillWantParams{ID: w.ID, AlbumID: album}); err != nil {
return fmt.Errorf("wanted: fulfil: %w", err)
}
res.Held++
}
return nil
}
// shortestEdition is the track count of the group's shortest official
// edition (any edition when none is official), or 0 when the catalogue
// cannot say. moved reports that the want was recorded under a release id
// and has been moved to its group.
func (s *Service) shortestEdition(ctx context.Context, q *dbq.Queries, w dbq.Want, mbDown *bool) (int, bool, error) {
var releases []dbq.MbRelease
if *mbDown {
held, err := q.ListMBReleasesForGroup(ctx, w.ReleaseGroupMbid)
if err != nil {
return 0, false, err
}
releases = held
} else {
ed, err := s.cat.Releases(ctx, w.ReleaseGroupMbid)
switch {
case err == nil:
for _, e := range ed.Editions {
releases = append(releases, e.Release)
}
case errors.Is(err, catalogue.ErrNotFound):
moved, merr := s.moveToGroup(ctx, w)
return 0, moved, merr
case errors.Is(err, musicbrainz.ErrUnreachable):
*mbDown = true
return s.shortestEdition(ctx, q, w, mbDown)
default:
return 0, false, err
}
}
return Shortest(releases), false, nil
}
// Shortest is the track count of the shortest official release with tracks,
// or of the shortest release with tracks when none is official, or 0.
func Shortest(releases []dbq.MbRelease) int {
best, bestAny := 0, 0
for _, r := range releases {
n := int(r.TrackCount)
if n <= 0 {
continue
}
if bestAny == 0 || n < bestAny {
bestAny = n
}
if r.Status == "Official" && (best == 0 || n < best) {
best = n
}
}
if best > 0 {
return best
}
return bestAny
}
// moveToGroup handles a want MusicBrainz has no release group under: one
// recorded under a release id before #5241. It is moved to the release's
// group, with the requests that name it; when the group is already wanted,
// this copy is dropped.
func (s *Service) moveToGroup(ctx context.Context, w dbq.Want) (bool, error) {
group, err := s.releaseGroup(ctx, w.ReleaseGroupMbid)
if err != nil {
if errors.Is(err, tags.ErrNotFound) {
return false, nil
}
return false, fmt.Errorf("release group of %s: %w", w.ReleaseGroupMbid, err)
}
if group == "" || group == w.ReleaseGroupMbid {
return false, nil
}
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
tq := dbq.New(tx)
if _, gerr := tq.GetWantByReleaseGroup(ctx, group); gerr == nil {
if _, derr := tq.DeleteWant(ctx, w.ID); derr != nil {
return derr
}
} else if errors.Is(gerr, pgx.ErrNoRows) {
if rerr := tq.RepointWant(ctx, dbq.RepointWantParams{ID: w.ID, ReleaseGroupMbid: group}); rerr != nil {
return rerr
}
} else {
return gerr
}
return tq.RepointRequestsAlbumMbid(ctx, dbq.RepointRequestsAlbumMbidParams{
NewMbid: &group, OldMbid: &w.ReleaseGroupMbid,
})
})
if err != nil {
return false, fmt.Errorf("move want to its group: %w", err)
}
s.logger.Info("wanted: want moved from a release id to its group",
"want", w.Title, "release_mbid", w.ReleaseGroupMbid, "release_group_mbid", group)
return true, nil
}
// answeredBy finds the library album that answers a want, if any:
//
// - for a want about an album whose files went missing, that album once no
// missing track is left without a track on disk in its place;
// - otherwise an album of the group with at least as many tracks on disk as
// the shortest official edition;
// - and when the catalogue cannot say how long the group is, the test from
// #5263: a track arrived since the want, or nothing missing before it is
// still missing.
//
// Only albums with a track on disk count.
func (s *Service) answeredBy(ctx context.Context, q *dbq.Queries, w dbq.Want, shortest int) (pgtype.UUID, bool, error) {
holdings, err := q.WantHoldings(ctx, dbq.WantHoldingsParams{
Since: w.WantedAt, GroupMbid: w.ReleaseGroupMbid, AlbumID: w.AlbumID,
})
if err != nil {
return pgtype.UUID{}, false, fmt.Errorf("wanted: read holdings: %w", err)
}
for _, h := range holdings {
if Answers(w, h, shortest) {
return h.AlbumID, true, nil
}
}
return pgtype.UUID{}, false, nil
}
// Answers is answeredBy's verdict on one album.
func Answers(w dbq.Want, h dbq.WantHoldingsRow, shortest int) bool {
if h.Present == 0 {
return false
}
if w.Reason == ReasonMissing && w.AlbumID.Valid && h.AlbumID == w.AlbumID {
return h.UncoveredMissing == 0
}
if shortest > 0 {
return int(h.Present) >= shortest
}
return h.ArrivedSince || !h.MissingBefore
}
+457
View File
@@ -0,0 +1,457 @@
package wanted
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
func TestPasses_PrimaryListedAndEverySecondaryAllowed(t *testing.T) {
standard := dbq.ReleaseTypeProfile{PrimaryTypes: []string{"Album", "EP", "Single"}, SecondaryTypes: []string{}}
withLive := dbq.ReleaseTypeProfile{PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{"Live"}}
for _, c := range []struct {
profile dbq.ReleaseTypeProfile
primary string
secondary []string
want bool
}{
{standard, "Album", nil, true},
{standard, "single", nil, true}, // MusicBrainz's case is not relied on
{standard, "Album", []string{"Live"}, false},
{standard, "Broadcast", nil, false},
{withLive, "Album", []string{"Live"}, true},
{withLive, "Album", []string{"Live", "Compilation"}, false},
{withLive, "EP", nil, false},
} {
if got := Passes(c.profile, c.primary, c.secondary); got != c.want {
t.Errorf("Passes(%v/%v, %s %v) = %v, want %v", c.profile.PrimaryTypes, c.profile.SecondaryTypes,
c.primary, c.secondary, got, c.want)
}
}
}
func TestProfileInput_NormalizesToMusicBrainzTypes(t *testing.T) {
in, err := ProfileInput{Name: " Live too ", PrimaryTypes: []string{"single", "Album"}, SecondaryTypes: []string{"live"}}.normalize()
if err != nil {
t.Fatal(err)
}
if in.Name != "Live too" || fmt.Sprint(in.PrimaryTypes) != "[Album Single]" || fmt.Sprint(in.SecondaryTypes) != "[Live]" {
t.Errorf("normalized = %+v", in)
}
for name, bad := range map[string]ProfileInput{
"no name": {PrimaryTypes: []string{"Album"}},
"no primary type": {Name: "x"},
"unknown type": {Name: "x", PrimaryTypes: []string{"Mixtape"}},
} {
if _, err := bad.normalize(); !errors.Is(err, ErrProfileInvalid) {
t.Errorf("%s: err = %v", name, err)
}
}
}
func TestRecent_ReadsAPartialDateAsItsLastDay(t *testing.T) {
s := &Service{}
baseline := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
for date, want := range map[string]bool{
"": true, // not yet known: an announced release
"2026-11-01": true,
"2026-09-15": true, // inside the grace before the follow
"2026-08-01": false,
"2026": true, // could be December
"2025": false,
"2026-09": true,
"1998-04-01": false,
} {
if got := s.recent(date, baseline); got != want {
t.Errorf("recent(%q) = %v, want %v", date, got, want)
}
}
}
func TestShortest_PrefersOfficialEditions(t *testing.T) {
rel := func(status string, n int32) dbq.MbRelease { return dbq.MbRelease{Status: status, TrackCount: n} }
for _, c := range []struct {
releases []dbq.MbRelease
want int
}{
{nil, 0},
{[]dbq.MbRelease{rel("Official", 14), rel("Official", 12), rel("Promotion", 3)}, 12},
{[]dbq.MbRelease{rel("Bootleg", 9), rel("", 11)}, 9},
{[]dbq.MbRelease{rel("Official", 0), rel("Bootleg", 9)}, 9},
} {
if got := Shortest(c.releases); got != c.want {
t.Errorf("Shortest(%v) = %d, want %d", c.releases, got, c.want)
}
}
}
func TestAnswers(t *testing.T) {
album := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
other := pgtype.UUID{Bytes: [16]byte{2}, Valid: true}
missing := dbq.Want{Reason: ReasonMissing, AlbumID: album}
request := dbq.Want{Reason: ReasonRequest}
for name, c := range map[string]struct {
w dbq.Want
h dbq.WantHoldingsRow
shortest int
want bool
}{
"nothing on disk": {request, dbq.WantHoldingsRow{AlbumID: other, ArrivedSince: true}, 10, false},
"a full edition": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 10}, 10, true},
"short of every edition": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 9, ArrivedSince: true}, 10, false},
"no editions known, an arrival": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 1, ArrivedSince: true, MissingBefore: true}, 0, true},
"no editions known, still missing": {request, dbq.WantHoldingsRow{AlbumID: other, Present: 5, MissingBefore: true}, 0, false},
"the lost album, a gap remains": {missing, dbq.WantHoldingsRow{AlbumID: album, Present: 12, UncoveredMissing: 1}, 10, false},
"the lost album, every gap filled": {missing, dbq.WantHoldingsRow{AlbumID: album, Present: 9, UncoveredMissing: 0}, 10, true},
"another copy of the lost album": {missing, dbq.WantHoldingsRow{AlbumID: other, Present: 10}, 10, true},
} {
if got := Answers(c.w, c.h, c.shortest); got != c.want {
t.Errorf("%s: Answers = %v, want %v", name, got, c.want)
}
}
}
func TestFollowable(t *testing.T) {
for _, c := range []struct {
mbid, name string
want bool
}{
{"a", "Gorillaz", true},
{variousArtists, "Various Artists", false},
{"b", "[unknown]", false},
{"", "Nameless", false},
{"c", "[ ]Bracketed Band", true},
} {
if got := Followable(c.mbid, c.name); got != c.want {
t.Errorf("Followable(%q, %q) = %v", c.mbid, c.name, got)
}
}
}
// --- integration ---
func newPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
const (
blur = "ba853904-ae25-4ebb-89d6-c44cfbd71bd2"
parklife = "11111111-0000-4000-8000-000000000001" // 1994 album, held
liveAtWemb = "11111111-0000-4000-8000-000000000002" // 2009 live album
theBallad = "11111111-0000-4000-8000-000000000003" // 2023 album, not held
newSingle = "11111111-0000-4000-8000-000000000004" // appears after the baseline
oldSingle = "11111111-0000-4000-8000-000000000005" // 1997 single, catalogued late
parkRelease = "22222222-0000-4000-8000-000000000001"
)
// fakeMB is a MusicBrainz holding Blur's discography; added groups appear in
// it after the baseline, and down makes every request a 503.
type fakeMB struct {
srv *httptest.Server
added atomic.Bool
down atomic.Bool
}
func newFakeMB(t *testing.T) *fakeMB {
t.Helper()
f := &fakeMB{}
group := func(id, title, primary, secondary, date string) string {
return fmt.Sprintf(`{"id": %q, "title": %q, "primary-type": %q, "secondary-types": [%s], "first-release-date": %q,
"artist-credit": [{"name": "Blur", "artist": {"id": %q}}]}`, id, title, primary, secondary, date, blur)
}
f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if f.down.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
switch {
case r.URL.Path == "/artist/"+blur:
_, _ = fmt.Fprintf(w, `{"id": %q, "name": "Blur", "type": "Group", "country": "GB"}`, blur)
case r.URL.Path == "/release-group" && r.URL.Query().Get("artist") == blur:
groups := group(parklife, "Parklife", "Album", "", "1994-04-25") + "," +
group(liveAtWemb, "Live at Wembley Stadium", "Album", `"Live"`, "2009-07-02") + "," +
group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21")
n := 3
if f.added.Load() {
groups += "," + group(newSingle, "New Single", "Single", "", "") +
"," + group(oldSingle, "Old Single", "Single", "", "1997-02-03")
n = 5
}
_, _ = fmt.Fprintf(w, `{"release-group-count": %d, "release-groups": [%s]}`, n, groups)
case r.URL.Path == "/release-group/"+theBallad:
_, _ = fmt.Fprint(w, group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21"))
case r.URL.Path == "/release" && r.URL.Query().Get("release-group") == theBallad:
_, _ = fmt.Fprint(w, `{"release-count": 2, "releases": [
{"id": "r-ballad-1", "title": "The Ballad of Darren", "status": "Official", "media": [{"position": 1,
"tracks": [{"position": 1, "number": "1", "title": "The Ballad", "recording": {"id": "rec-1"}},
{"position": 2, "number": "2", "title": "St. Charles Square", "recording": {"id": "rec-2"}}]}]},
{"id": "r-ballad-2", "title": "The Ballad of Darren", "status": "Official", "media": [{"position": 1,
"tracks": [{"position": 1, "number": "1", "title": "The Ballad", "recording": {"id": "rec-1"}},
{"position": 2, "number": "2", "title": "St. Charles Square", "recording": {"id": "rec-2"}},
{"position": 3, "number": "3", "title": "Bonus", "recording": {"id": "rec-3"}}]}]}]}`)
case r.URL.Path == "/release":
_, _ = fmt.Fprint(w, `{"release-count": 0, "releases": []}`)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(f.srv.Close)
return f
}
func newService(t *testing.T, pool *pgxpool.Pool, f *fakeMB) *Service {
t.Helper()
mb := &musicbrainz.Client{BaseURL: f.srv.URL, Pace: &musicbrainz.Pacer{}, Retries: -1}
s := New(pool, catalogue.New(pool, mb, nil), slog.New(slog.NewTextHandler(io.Discard, nil)))
// Never the real MusicBrainz from a test.
s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrNotFound }
return s
}
// seedHeld puts an album of Blur's in the library with n tracks on disk.
func seedHeld(t *testing.T, q *dbq.Queries, title, release, group string, n int) dbq.Album {
t.Helper()
ctx := context.Background()
mbid := blur
artist, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "Blur", SortName: "Blur", Mbid: &mbid})
if err != nil {
t.Fatal(err)
}
params := dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID, Mbid: &release}
if group != "" {
params.ReleaseGroupMbid = &group
}
album, err := q.UpsertAlbum(ctx, params)
if err != nil {
t.Fatal(err)
}
for i := range n {
num := int32(i + 1)
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: fmt.Sprintf("Track %d", i+1), AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num,
DurationMs: 180000, FilePath: fmt.Sprintf("/music/%s/%02d.flac", release, i+1), FileSize: 1, FileFormat: "flac",
}); err != nil {
t.Fatal(err)
}
}
return album
}
func TestFollow_BaselineThenOnlyNewReleasesThatPass_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
f := newFakeMB(t)
s := newService(t, pool, f)
_ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2)
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur"}); err != nil {
t.Fatal(err)
}
res, err := s.Pass(ctx)
if err != nil {
t.Fatal(err)
}
if res.Baselined != 1 || res.Wanted != 0 {
t.Fatalf("baseline pass = %+v: a follow wants nothing it already lists", res)
}
// MusicBrainz gains two singles: one new, one from 1997. A day later the
// follow reads the discography again.
f.added.Store(true)
if _, err := pool.Exec(ctx, `
UPDATE mb_artists SET discography_refreshed_at = now() - interval '2 days';
UPDATE follows SET checked_at = now() - interval '2 days'`); err != nil {
t.Fatal(err)
}
if res, err = s.Pass(ctx); err != nil {
t.Fatal(err)
}
if res.Wanted != 1 {
t.Errorf("second pass wanted %d, want 1 (the new single)", res.Wanted)
}
if w, err := q.GetWantByReleaseGroup(ctx, newSingle); err != nil || w.Reason != ReasonFollow || w.PrimaryType != "Single" {
t.Errorf("new single: %+v, %v", w, err)
}
if _, err := q.GetWantByReleaseGroup(ctx, oldSingle); err == nil {
t.Error("a 1997 single catalogued late is not a new release")
}
}
func TestFollow_WantExistingWantsWhatPassesAndIsNotHeld_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
f := newFakeMB(t)
s := newService(t, pool, f)
_ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2)
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur", WantExisting: true}); err != nil {
t.Fatal(err)
}
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
t.Errorf("the studio album not held should be wanted: %v", err)
}
for group, why := range map[string]string{parklife: "held", liveAtWemb: "live, and Standard is studio only"} {
if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
t.Errorf("%s wanted, but it is %s", group, why)
}
}
if fol, _ := q.GetFollow(ctx, blur); fol.WantExisting || !fol.BaselineAt.Valid {
t.Errorf("follow = %+v: want_existing applied once, then cleared", fol)
}
// A profile that allows live releases, applied with "want what exists".
live, err := s.CreateProfile(ctx, ProfileInput{Name: "With live", PrimaryTypes: []string{"Album"}, SecondaryTypes: []string{"Live"}})
if err != nil {
t.Fatal(err)
}
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur", ProfileID: live.ID, WantExisting: true}); err != nil {
t.Fatal(err)
}
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, liveAtWemb); err != nil {
t.Errorf("with live allowed, the live album is wanted: %v", err)
}
if err := s.DeleteProfile(ctx, live.ID); !errors.Is(err, ErrProfileInUse) {
t.Errorf("deleting a profile in use: err = %v", err)
}
}
func TestSettle_HeldOnceAFullEditionIsOnDisk_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
s := newService(t, pool, newFakeMB(t))
if _, err := WantWith(ctx, q, WantParams{
GroupMBID: theBallad, ArtistMBID: blur, ArtistName: "Blur", Title: "The Ballad of Darren", Reason: ReasonRequest,
}); err != nil {
t.Fatal(err)
}
album := seedHeld(t, q, "The Ballad of Darren", "r-ballad-1", theBallad, 1)
if res, err := s.Pass(ctx); err != nil || res.Held != 0 {
t.Fatalf("one track of a two-track edition: %+v, %v", res, err)
}
_ = seedHeld(t, q, "The Ballad of Darren", "r-ballad-1", theBallad, 2)
if res, err := s.Pass(ctx); err != nil || res.Held != 1 {
t.Fatalf("the shortest edition complete: %+v, %v", res, err)
}
w, _ := q.GetWantByReleaseGroup(ctx, theBallad)
if !w.FulfilledAt.Valid || w.AlbumID != album.ID {
t.Errorf("want = %+v: held by the album", w)
}
}
func TestSettle_MovesAWantNamedByReleaseIdToItsGroup_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
s := newService(t, pool, newFakeMB(t))
s.releaseGroup = func(_ context.Context, release string) (string, error) {
if release == "r-ballad-1" {
return theBallad, nil
}
return "", tags.ErrNotFound
}
if _, err := WantWith(ctx, q, WantParams{
GroupMBID: "r-ballad-1", ArtistMBID: blur, ArtistName: "Blur", Title: "The Ballad of Darren", Reason: ReasonRequest,
}); err != nil {
t.Fatal(err)
}
group := "r-ballad-1"
if _, err := q.CreateRequest(ctx, dbq.CreateRequestParams{
UserID: seedUser(t, pool), Kind: dbq.RequestKindAlbum, ArtistMbid: blur, AlbumMbid: &group, ArtistName: "Blur",
}); err != nil {
t.Fatal(err)
}
if _, err := s.Pass(ctx); err != nil {
t.Fatal(err)
}
if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
t.Errorf("the want should be under its group now: %v", err)
}
var moved string
if err := pool.QueryRow(ctx, "SELECT album_mbid FROM requests").Scan(&moved); err != nil || moved != theBallad {
t.Errorf("the request should follow its want: %q, %v", moved, err)
}
}
func TestPass_MusicBrainzDownStopsFollowsButStillSettles_Integration(t *testing.T) {
pool := newPool(t)
q := dbq.New(pool)
ctx := context.Background()
f := newFakeMB(t)
s := newService(t, pool, f)
if _, err := s.Follow(ctx, FollowParams{ArtistMBID: blur, ArtistName: "Blur"}); err != nil {
t.Fatal(err)
}
if _, err := WantWith(ctx, q, WantParams{
GroupMBID: "some-group", ArtistMBID: blur, ArtistName: "Blur", Title: "Some", Reason: ReasonRequest,
}); err != nil {
t.Fatal(err)
}
_ = seedHeld(t, q, "Some", "some-release", "some-group", 3)
f.down.Store(true)
res, err := s.Pass(ctx)
if !errors.Is(err, musicbrainz.ErrUnreachable) {
t.Errorf("err = %v, want ErrUnreachable", err)
}
if res.Held != 1 {
t.Errorf("held = %d: with no editions known, an album that arrived since the want answers it", res.Held)
}
if fol, _ := q.GetFollow(ctx, blur); fol.BaselineAt.Valid {
t.Error("a follow is not baselined without reading its discography")
}
}
func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
t.Helper()
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "wanter", PasswordHash: "x", ApiTokenHash: "x",
})
if err != nil {
t.Fatal(err)
}
return u.ID
}