Files
minstrel/internal/reacquisition/sweeper.go
T
bvandeusenandClaude Opus 5.5 460e78c955
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m24s
release / go (push) Successful in 1m32s
release / integration (push) Successful in 5m1s
release / android (push) Successful in 5m7s
release / Build signed APK (releases and dev) (push) Successful in 5m17s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 16s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: an album with missing tracks is wanted back by its release group, not only when it carries a release MBID (#6167)
The release id was only ever used to find the group. 238 albums matched to a
group carried none, so their losses, five of the resolver's wrong-slot
removals among them, were never fetched again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:21:06 -04:00

168 lines
5.5 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.
hasGroup := album.AlbumReleaseGroupMbid != nil && *album.AlbumReleaseGroupMbid != ""
if (album.AlbumMbid == nil && !hasGroup) || 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,
)
}