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>
167 lines
5.4 KiB
Go
167 lines
5.4 KiB
Go
package reacquisition
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
|
)
|
|
|
|
// Sweeper periodically wants back albums whose files have been missing past
|
|
// the grace window (milestone #290, M502 #5520). Searching for them is the
|
|
// search schedule's (#5521), which retries per want.
|
|
//
|
|
// Deliberately a periodic worker rather than a hook inside the scanner's
|
|
// reconcile pass: reconcile runs inside a scan and re-runs often, and a
|
|
// filesystem lies transiently (an unmounted volume, a NAS mid-reboot) in ways
|
|
// the grace window exists to ride out.
|
|
type Sweeper struct {
|
|
pool *pgxpool.Pool
|
|
settings *SettingsService
|
|
logger *slog.Logger
|
|
tick time.Duration
|
|
// releaseGroup names the MusicBrainz release group of a release id, for an
|
|
// album whose tags never carried one (#5241). Wants are kept by release
|
|
// group. A field so tests can stand in for MusicBrainz.
|
|
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
|
|
}
|
|
|
|
// NewSweeper constructs a Sweeper. The tick is deliberately coarse: the grace
|
|
// window is measured in hours.
|
|
func NewSweeper(pool *pgxpool.Pool, settings *SettingsService, logger *slog.Logger) *Sweeper {
|
|
return &Sweeper{
|
|
pool: pool,
|
|
settings: settings,
|
|
logger: logger,
|
|
tick: 1 * time.Hour,
|
|
releaseGroup: tags.ReleaseGroupForRelease,
|
|
}
|
|
}
|
|
|
|
// Run drives the sweep loop until ctx is cancelled.
|
|
func (s *Sweeper) Run(ctx context.Context) {
|
|
t := time.NewTicker(s.tick)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if err := s.SweepOnce(ctx); err != nil {
|
|
s.logger.Warn("reacquisition: sweep failed", "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// PassResult reports what a single sweep did, for logging and tests.
|
|
type PassResult struct {
|
|
Wanted int // albums newly wanted, or wanted again after being held
|
|
Unnameable int64 // albums with missing files but no MBID to want them by
|
|
// Unresolved: albums skipped this pass because MusicBrainz could not name
|
|
// their release group; the next pass tries again.
|
|
Unresolved int
|
|
}
|
|
|
|
// SweepOnce runs one pass. Exported so tests drive it directly.
|
|
func (s *Sweeper) SweepOnce(ctx context.Context) error {
|
|
cfg := s.settings.Get()
|
|
if !cfg.Enabled {
|
|
return nil
|
|
}
|
|
q := dbq.New(s.pool)
|
|
var res PassResult
|
|
// Counted, not acted on: an album MusicBrainz cannot name is not a
|
|
// failure to retry, it is a permanent gap the operator should see.
|
|
if n, cerr := q.CountAlbumsMissingWithoutMbid(ctx); cerr == nil {
|
|
res.Unnameable = n
|
|
}
|
|
due, err := q.ListAlbumsDueReacquisition(ctx, dbq.ListAlbumsDueReacquisitionParams{
|
|
GraceHours: cfg.GraceHours,
|
|
PageLimit: cfg.MaxPerPass,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("list due: %w", err)
|
|
}
|
|
for _, album := range due {
|
|
if err := s.want(ctx, q, album, &res); err != nil {
|
|
// One album's failure must not abandon the rest of the pass.
|
|
s.logger.Warn("reacquisition: want failed", "album", album.AlbumTitle, "err", err)
|
|
}
|
|
}
|
|
s.logSummary(res)
|
|
return nil
|
|
}
|
|
|
|
// want records one album as wanted under its release group.
|
|
func (s *Sweeper) want(ctx context.Context, q *dbq.Queries, album dbq.ListAlbumsDueReacquisitionRow, res *PassResult) error {
|
|
// The query already filters these out; belt and braces.
|
|
if album.AlbumMbid == nil || album.ArtistMbid == nil {
|
|
return nil
|
|
}
|
|
group, err := s.albumReleaseGroup(ctx, q, album)
|
|
if err != nil {
|
|
if errors.Is(err, tags.ErrNotFound) {
|
|
res.Unresolved++
|
|
s.logger.Info("reacquisition: no MusicBrainz release group for album; skipped",
|
|
"album", album.AlbumTitle, "release_mbid", *album.AlbumMbid)
|
|
return nil
|
|
}
|
|
return fmt.Errorf("release group: %w", err)
|
|
}
|
|
_, err = q.WantMissingAlbum(ctx, dbq.WantMissingAlbumParams{
|
|
ReleaseGroupMbid: group, ArtistMbid: *album.ArtistMbid, ArtistName: album.ArtistName,
|
|
Title: album.AlbumTitle, AlbumID: album.AlbumID,
|
|
})
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Already wanted, open: by a request, a follow, or this sweeper.
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("want: %w", err)
|
|
}
|
|
res.Wanted++
|
|
return nil
|
|
}
|
|
|
|
// albumReleaseGroup is the album's release group: the one its tags carried,
|
|
// else MusicBrainz's answer for its release id, cached onto the album so the
|
|
// next pass need not ask again. Returns tags.ErrNotFound when MusicBrainz
|
|
// cannot name it.
|
|
func (s *Sweeper) albumReleaseGroup(ctx context.Context, q *dbq.Queries, album dbq.ListAlbumsDueReacquisitionRow) (string, error) {
|
|
if album.AlbumReleaseGroupMbid != nil && *album.AlbumReleaseGroupMbid != "" {
|
|
return *album.AlbumReleaseGroupMbid, nil
|
|
}
|
|
group, err := s.releaseGroup(ctx, *album.AlbumMbid)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if cerr := q.SetAlbumReleaseGroupMbidIfNull(ctx, dbq.SetAlbumReleaseGroupMbidIfNullParams{
|
|
ID: album.AlbumID,
|
|
ReleaseGroupMbid: &group,
|
|
}); cerr != nil {
|
|
s.logger.Warn("reacquisition: cache release group failed", "album", album.AlbumTitle, "err", cerr)
|
|
}
|
|
return group, nil
|
|
}
|
|
|
|
func (s *Sweeper) logSummary(res PassResult) {
|
|
// Silence when a pass did nothing at all — this runs hourly forever, and
|
|
// an unconditional line would bury the passes that mattered.
|
|
if res.Wanted == 0 && res.Unresolved == 0 {
|
|
return
|
|
}
|
|
s.logger.Info("reacquisition: sweep",
|
|
"wanted", res.Wanted,
|
|
"unnameable", res.Unnameable,
|
|
"unresolved", res.Unresolved,
|
|
)
|
|
}
|