release / go (push) Successful in 2m33s
release / web (push) Successful in 1m41s
release / govulncheck (push) Successful in 22s
release / integration (push) Successful in 6m0s
release / android (push) Successful in 6m7s
release / Build signed APK (releases and dev) (push) Successful in 6m8s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m43s
release / Verify release artifacts (tag releases only) (push) Skipped
Lidarr's metadata is keyed by MusicBrainz release group, but re-acquisition requested albums by their release id, so every add came back "not found". - Sweeper requests an album by its tag-supplied release group, else the one MusicBrainz names (cached onto the album). An album MusicBrainz cannot name is skipped and counted, with no attempt spent. - Reconciler: an add refused as not found re-reads the request's album id as a release (library first, then MusicBrainz), rewrites the request to the group and adds again. This repairs the requests already stored. - Completion matches an album by release id or release group, and only once a track of it is on disk, so a re-acquisition request no longer completes against the row of the album it is trying to bring back. Closes #5241. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
9.1 KiB
Go
273 lines
9.1 KiB
Go
package reacquisition
|
|
|
|
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/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
|
)
|
|
|
|
// requestCreator is the slice of lidarrrequests.Service the sweeper needs,
|
|
// narrowed to an interface so the pass can be tested without a Lidarr client
|
|
// or an approval path that talks to one.
|
|
type requestCreator interface {
|
|
Create(ctx context.Context, userID pgtype.UUID, p lidarrrequests.CreateParams) (dbq.LidarrRequest, error)
|
|
Approve(ctx context.Context, requestID, adminID pgtype.UUID, ov lidarrrequests.ApproveOverrides) (dbq.LidarrRequest, error)
|
|
}
|
|
|
|
// Sweeper periodically turns albums with long-missing files into Lidarr
|
|
// requests (milestone #290).
|
|
//
|
|
// Deliberately a periodic worker rather than a hook inside the scanner's
|
|
// reconcile pass. Reconcile runs inside a scan and has no business deciding
|
|
// to talk to a third-party service; it also re-runs often, which would make
|
|
// "attempt once, then back off" awkward to express. A worker paces itself,
|
|
// survives a restart, and retries without needing another scan.
|
|
type Sweeper struct {
|
|
pool *pgxpool.Pool
|
|
settings *SettingsService
|
|
requests requestCreator
|
|
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). Lidarr knows albums only 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
|
|
// smallest meaningful backoff is measured in hours, so waking more often than
|
|
// hourly would only re-read settings and find nothing due.
|
|
func NewSweeper(
|
|
pool *pgxpool.Pool,
|
|
settings *SettingsService,
|
|
requests requestCreator,
|
|
logger *slog.Logger,
|
|
) *Sweeper {
|
|
return &Sweeper{
|
|
pool: pool,
|
|
settings: settings,
|
|
requests: requests,
|
|
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 {
|
|
Cleared int64 // albums whose files came back; state dropped
|
|
Requested int // requests created this pass
|
|
Approved int // of those, sent on to Lidarr
|
|
GaveUp int // albums that spent their attempt budget
|
|
Unnameable int64 // albums with missing files but no MBID to ask for
|
|
// Unresolved: albums skipped this pass because MusicBrainz could not name
|
|
// their release group (switched off, or no such release). Lidarr cannot be
|
|
// asked for them, and no attempt is spent; the next pass tries again.
|
|
Unresolved int
|
|
}
|
|
|
|
// SweepOnce runs one pass. Exported so the admin surface can offer a "run
|
|
// now" without waiting out the tick, and 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)
|
|
|
|
// Before selecting work: drop state for albums whose files came back, or
|
|
// were adopted at a new path (#2528). Doing this first means a recovered
|
|
// album cannot be picked in the same pass that would have retried it.
|
|
cleared, err := q.ClearRecoveredReacquisitions(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("clear recovered: %w", err)
|
|
}
|
|
res := PassResult{Cleared: cleared}
|
|
|
|
// 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,
|
|
BackoffBaseHours: cfg.BackoffBaseHours,
|
|
BackoffMaxHours: cfg.BackoffMaxHours,
|
|
PageLimit: cfg.MaxPerPass,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("list due: %w", err)
|
|
}
|
|
if len(due) == 0 {
|
|
s.logSummary(res)
|
|
return nil
|
|
}
|
|
|
|
// One admin lookup per pass, not per album. A library with no admin at
|
|
// all cannot own a request, so the pass stops rather than half-working.
|
|
admin, err := q.GetOldestAdmin(ctx)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
s.logger.Warn("reacquisition: no admin account to own requests; skipping pass")
|
|
return nil
|
|
}
|
|
return fmt.Errorf("owner lookup: %w", err)
|
|
}
|
|
|
|
for _, album := range due {
|
|
if err := s.attempt(ctx, q, cfg, admin.ID, album, &res); err != nil {
|
|
// One album's failure must not abandon the rest of the pass: a
|
|
// single unmatched MBID or a transient Lidarr error says nothing
|
|
// about the next album in the list.
|
|
s.logger.Warn("reacquisition: attempt failed",
|
|
"album", album.AlbumTitle, "err", err)
|
|
}
|
|
}
|
|
s.logSummary(res)
|
|
return nil
|
|
}
|
|
|
|
// attempt creates (and optionally approves) the request for one album, then
|
|
// records the attempt against its backoff budget.
|
|
func (s *Sweeper) attempt(
|
|
ctx context.Context,
|
|
q *dbq.Queries,
|
|
cfg Settings,
|
|
adminID pgtype.UUID,
|
|
album dbq.ListAlbumsDueReacquisitionRow,
|
|
res *PassResult,
|
|
) error {
|
|
// The query already filters these out; belt and braces, because Create
|
|
// would reject the request and burn an attempt for no reason.
|
|
if album.AlbumMbid == nil || album.ArtistMbid == nil {
|
|
return nil
|
|
}
|
|
|
|
// Lidarr names albums by release group; albums.mbid is the release.
|
|
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)
|
|
}
|
|
|
|
req, err := s.requests.Create(ctx, adminID, lidarrrequests.CreateParams{
|
|
Kind: "album",
|
|
LidarrArtistMBID: *album.ArtistMbid,
|
|
ArtistName: album.ArtistName,
|
|
LidarrAlbumMBID: group,
|
|
AlbumTitle: album.AlbumTitle,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create request: %w", err)
|
|
}
|
|
res.Requested++
|
|
|
|
// Record the attempt even when Create deduped into somebody else's
|
|
// existing request: the point of the counter is "how often have we gone
|
|
// looking for this album", and a manual request in flight is a reason to
|
|
// wait rather than to keep asking.
|
|
row, err := q.RecordReacquisitionAttempt(ctx, dbq.RecordReacquisitionAttemptParams{
|
|
AlbumID: album.AlbumID,
|
|
LastRequestID: req.ID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("record attempt: %w", err)
|
|
}
|
|
|
|
if cfg.AutoApprove {
|
|
_, aerr := s.requests.Approve(ctx, req.ID, adminID, lidarrrequests.ApproveOverrides{})
|
|
switch {
|
|
case aerr == nil:
|
|
res.Approved++
|
|
case errors.Is(aerr, lidarrrequests.ErrLidarrDisabled):
|
|
// Leave it pending rather than treating it as a failure. The
|
|
// request is still the right record of intent, and it becomes
|
|
// actionable the moment Lidarr is configured.
|
|
s.logger.Info("reacquisition: request left pending, Lidarr disabled",
|
|
"album", album.AlbumTitle)
|
|
case errors.Is(aerr, lidarrrequests.ErrNotPending):
|
|
// Deduped onto a request somebody already approved. Nothing to do
|
|
// and nothing wrong.
|
|
default:
|
|
return fmt.Errorf("approve: %w", aerr)
|
|
}
|
|
}
|
|
|
|
if row.Attempts >= cfg.MaxAttempts {
|
|
if err := q.MarkReacquisitionGaveUp(ctx, album.AlbumID); err != nil {
|
|
return fmt.Errorf("mark gave up: %w", err)
|
|
}
|
|
res.GaveUp++
|
|
}
|
|
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 (and request completion) 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.Requested == 0 && res.Cleared == 0 && res.GaveUp == 0 && res.Unresolved == 0 {
|
|
return
|
|
}
|
|
s.logger.Info("reacquisition: sweep",
|
|
"requested", res.Requested,
|
|
"approved", res.Approved,
|
|
"gave_up", res.GaveUp,
|
|
"cleared", res.Cleared,
|
|
"unnameable", res.Unnameable,
|
|
"unresolved", res.Unresolved,
|
|
)
|
|
}
|