Files
minstrel/internal/wanted/wanted.go
T
bvandeusenandClaude Opus 5.5 ca57133c8a
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m31s
release / go (push) Successful in 1m51s
release / android (push) Successful in 5m18s
release / integration (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 21s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: a release group whose title names a live, remix or demo version is wanted only when the profile allows that version (M502 #5573)
MusicBrainz types My Chemical Romance's "Common People (Recorded For BBC
Radio 1)" as a plain Single, so a studio-only follow wanted it. Passes now
reads the title's qualifier (brackets, or after a dash) for the version it
names and treats it as a secondary type; a version no type names, an
acoustic or instrumental one, no profile passes. The follow check takes off
the list an open follow want the rule now refuses. Judge counts bbc,
sessions and unplugged as live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:39:35 -04:00

536 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)
}
// 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 || res.Listed > 0:
s.logger.Info("wanted: pass complete", "follows", res.Follows, "baselined", res.Baselined,
"wanted", res.Wanted, "held", res.Held, "listed", res.Listed)
}
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)
}
// 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
}
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
}