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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user