Files
minstrel/internal/wanted/wanted.go
T
bvandeusenandClaude Opus 5.5 c54792c51e
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m41s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m28s
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 27s
release / Verify release artifacts (tag releases only) (push) Skipped
feat: a held album below the quality you settle for, or holding a video rip, stays wanted for a better copy, and the import replaces the old tracks with their likes and plays carried over (M502 #5524)
- acquisition_settings.upgrade_until (default High quality lossy, Lidarr's
  cutoff, note #5527; "" is off), an "Upgrade until" row under Searching.
- The wanted worker judges held albums hourly: tier per format from size per
  second (the scanner records no bitrate), the album's worst. Below the
  cutoff it is wanted for quality; a video rip among its tracks always.
- The schedule searches upgrades weekly, after open wants; a release no
  better than the copy held is refused ("not_better").
- The import matches each file to the track it would replace (recording,
  then disc/track) and replaces it when it is a rip or the file is a better
  tier: over the same path in one rename, or filed beside it and the old
  track folded in (library.ReplaceTrack: file first, then foldTrackInto).
  Nothing better: the release is set aside.
- Collection › Upgrades (Lidarr's Cutoff Unmet); Suspect sources says when a
  clean copy was looked for instead of Lidarr's search.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:24:59 -04:00

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, 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 || 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
}