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