release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
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
Migration 0091 drops lidarr_config and renames the quarantine tables, types, indexes and the history column off the Lidarr name. The Integrations Lidarr card, the Reports "Delete via Lidarr" action, the merge and remove-track unmonitor options, the Lidarr audit actions and error codes go, on the server, web and Android. The README describes acquisition as it is now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
545 lines
18 KiB
Go
545 lines
18 KiB
Go
// 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. The library's own albums were put on the list as held once (#5567),
|
|
// so the list is the record of what the library keeps. 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/release"
|
|
"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)
|
|
// upgradesChecked is when held albums were last judged for an upgrade
|
|
// (#5524); the worker alone touches it.
|
|
upgradesChecked time.Time
|
|
}
|
|
|
|
// 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.
|
|
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 || res.Listed > 0 || res.Upgrades > 0:
|
|
s.logger.Info("wanted: pass complete", "follows", res.Follows, "baselined", res.Baselined,
|
|
"wanted", res.Wanted, "held", res.Held, "listed", res.Listed, "upgrades", res.Upgrades)
|
|
}
|
|
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
|
|
Listed int // library albums put on the list as held (#5567)
|
|
// Upgrades: held albums wanted for a better copy, when the hourly check
|
|
// ran this pass (#5524).
|
|
Upgrades int
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
// The one-time listing of the library (#5567); a failure waits for the
|
|
// next pass and does not hold back settling.
|
|
if err := s.listLibrary(ctx, &res); err != nil && ctx.Err() == nil {
|
|
s.logger.Warn("wanted: list the library", "err", err)
|
|
}
|
|
if err := s.settleWants(ctx, &res); err != nil {
|
|
return res, err
|
|
}
|
|
if err := s.checkUpgrades(ctx, &res); err != nil && ctx.Err() == nil {
|
|
s.logger.Warn("wanted: judge held albums for upgrades", "err", 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))
|
|
passes := Passes(profile, g.Title, g.PrimaryType, g.SecondaryTypes)
|
|
if !passes {
|
|
if err := s.dropRefusedVersion(ctx, q, g.Mbid, g.Title); err != nil {
|
|
return wanted, err
|
|
}
|
|
}
|
|
if judge && passes {
|
|
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)
|
|
}
|
|
|
|
// dropRefusedVersion takes off the list a group the follow wanted before
|
|
// Passes read the version its title names (#5573), while it is still only
|
|
// wanted: one the operator holds, or wanted for another reason, stays.
|
|
func (s *Service) dropRefusedVersion(ctx context.Context, q *dbq.Queries, group, title string) error {
|
|
v, ok := release.VersionInTitle(title)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
w, err := q.GetWantByReleaseGroup(ctx, group)
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("read want %s: %w", title, err)
|
|
}
|
|
if w.Reason != ReasonFollow || w.FulfilledAt.Valid {
|
|
return nil
|
|
}
|
|
if _, err := q.DeleteWant(ctx, w.ID); err != nil {
|
|
return fmt.Errorf("drop want %s: %w", title, err)
|
|
}
|
|
s.logger.Info("wanted: a followed artist's release is another version, taken off the list",
|
|
"want", title, "version", v.What, "word", v.Word)
|
|
return nil
|
|
}
|
|
|
|
// 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
|
|
}
|