// 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/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)) 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 }