9490bad360
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
228 lines
6.5 KiB
Go
228 lines
6.5 KiB
Go
package scrobble
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"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/scrobble/listenbrainz"
|
|
)
|
|
|
|
// backoffSchedule maps the failure count (1-indexed) to the delay before
|
|
// the next attempt. Per spec §M4a: 1m → 5m → 30m → 2h → 6h, then give up.
|
|
var backoffSchedule = []time.Duration{
|
|
1 * time.Minute,
|
|
5 * time.Minute,
|
|
30 * time.Minute,
|
|
2 * time.Hour,
|
|
6 * time.Hour,
|
|
}
|
|
|
|
const maxAttempts = 5
|
|
|
|
// backoffDelay returns the delay before the next attempt given the value
|
|
// of the `attempts` column AFTER the failure has been recorded (1-indexed:
|
|
// attempts=1 means the first failure just happened, the next retry should
|
|
// wait 1 minute). Returns (_, false) when attempts > maxAttempts (the
|
|
// caller should mark the row failed).
|
|
func backoffDelay(attempts int) (time.Duration, bool) {
|
|
if attempts < 1 || attempts > maxAttempts {
|
|
return 0, false
|
|
}
|
|
return backoffSchedule[attempts-1], true
|
|
}
|
|
|
|
// Worker drains scrobble_queue rows and POSTs them to ListenBrainz.
|
|
type Worker struct {
|
|
pool *pgxpool.Pool
|
|
client *listenbrainz.Client
|
|
logger *slog.Logger
|
|
tick time.Duration
|
|
batch int32
|
|
}
|
|
|
|
// NewWorker constructs a worker with production defaults: 30s tick, 50-row
|
|
// batch.
|
|
func NewWorker(pool *pgxpool.Pool, client *listenbrainz.Client, logger *slog.Logger) *Worker {
|
|
return &Worker{pool: pool, client: client, logger: logger, tick: 30 * time.Second, batch: 50}
|
|
}
|
|
|
|
// Run blocks until ctx is cancelled, ticking every w.tick.
|
|
func (w *Worker) Run(ctx context.Context) {
|
|
t := time.NewTicker(w.tick)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
if err := w.tickOnce(ctx); err != nil {
|
|
w.logger.Error("scrobble: tick failed", "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// tickOnce drains up to w.batch pending rows. Returns the first error
|
|
// encountered loading rows; per-row errors are logged and don't abort
|
|
// the batch.
|
|
func (w *Worker) tickOnce(ctx context.Context) error {
|
|
q := dbq.New(w.pool)
|
|
rows, err := q.ListPendingScrobbles(ctx, w.batch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Group rows by user (each user has their own token + may produce its
|
|
// own batch). Map keyed by user UUID string.
|
|
type userBatch struct {
|
|
token string
|
|
queueIDs []pgtype.UUID
|
|
listens []listenbrainz.Listen
|
|
userID pgtype.UUID
|
|
}
|
|
batches := map[string]*userBatch{}
|
|
for _, r := range rows {
|
|
if r.LbToken == nil || *r.LbToken == "" || !r.LbEnabled {
|
|
// Defensive: row got into queue but user since disabled. Skip.
|
|
continue
|
|
}
|
|
key := r.UserID.String()
|
|
ub := batches[key]
|
|
if ub == nil {
|
|
ub = &userBatch{token: *r.LbToken, userID: r.UserID}
|
|
batches[key] = ub
|
|
}
|
|
ub.queueIDs = append(ub.queueIDs, r.QueueID)
|
|
l := listenbrainz.Listen{
|
|
ListenedAt: r.StartedAt.Time.Unix(),
|
|
Track: listenbrainz.Track{
|
|
ArtistName: r.ArtistName,
|
|
TrackName: r.TrackTitle,
|
|
ReleaseName: r.AlbumTitle,
|
|
DurationMs: int(r.TrackDurationMs),
|
|
},
|
|
}
|
|
if r.TrackMbid != nil {
|
|
l.Track.RecordingMBID = *r.TrackMbid
|
|
}
|
|
if r.AlbumMbid != nil {
|
|
l.Track.ReleaseMBID = *r.AlbumMbid
|
|
}
|
|
if r.ArtistMbid != nil {
|
|
l.Track.ArtistMBIDs = []string{*r.ArtistMbid}
|
|
}
|
|
ub.listens = append(ub.listens, l)
|
|
}
|
|
|
|
for _, ub := range batches {
|
|
w.processBatch(ctx, q, ub.userID, ub.token, ub.queueIDs, ub.listens)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// processBatch submits one user's pending batch and updates queue rows
|
|
// according to the response.
|
|
func (w *Worker) processBatch(
|
|
ctx context.Context,
|
|
q *dbq.Queries,
|
|
userID pgtype.UUID,
|
|
token string,
|
|
queueIDs []pgtype.UUID,
|
|
listens []listenbrainz.Listen,
|
|
) {
|
|
err := w.client.SubmitListens(ctx, token, listens)
|
|
now := time.Now().UTC()
|
|
|
|
if err == nil {
|
|
for _, id := range queueIDs {
|
|
if uerr := q.MarkScrobbleSent(ctx, id); uerr != nil {
|
|
w.logger.Error("scrobble: MarkScrobbleSent", "queue_id", id, "err", uerr)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// 429: schedule retry per Retry-After, do NOT increment attempts.
|
|
var ra *listenbrainz.RetryAfterError
|
|
if errors.As(err, &ra) {
|
|
next := pgtype.Timestamptz{Time: now.Add(ra.Wait), Valid: true}
|
|
for _, id := range queueIDs {
|
|
if uerr := q.MarkScrobbleRetryAfter(ctx, dbq.MarkScrobbleRetryAfterParams{
|
|
ID: id, NextAttemptAt: next,
|
|
}); uerr != nil {
|
|
w.logger.Error("scrobble: MarkScrobbleRetryAfter", "err", uerr)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// 401: mark failed AND disable user (defensive — bad token shouldn't keep
|
|
// retrying or feed future rows).
|
|
if errors.Is(err, listenbrainz.ErrAuth) {
|
|
w.logger.Warn("scrobble: auth rejected; disabling user listenbrainz", "user_id", userID)
|
|
if uerr := q.SetListenBrainzEnabled(ctx, dbq.SetListenBrainzEnabledParams{
|
|
ID: userID, ListenbrainzEnabled: false,
|
|
}); uerr != nil {
|
|
w.logger.Error("scrobble: SetListenBrainzEnabled", "err", uerr)
|
|
}
|
|
errStr := err.Error()
|
|
for _, id := range queueIDs {
|
|
if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{
|
|
ID: id, LastError: &errStr,
|
|
}); uerr != nil {
|
|
w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// 4xx: permanent. Mark failed, no retry.
|
|
if errors.Is(err, listenbrainz.ErrPermanent) {
|
|
errStr := err.Error()
|
|
for _, id := range queueIDs {
|
|
if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{
|
|
ID: id, LastError: &errStr,
|
|
}); uerr != nil {
|
|
w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// Transient (5xx, network): per-row, look up backoffDelay(currentAttempts+1).
|
|
// If OK → RescheduleScrobble (which increments). If NOT OK → MarkScrobbleFailed
|
|
// (don't increment further; current attempts already represents the cap).
|
|
errStr := err.Error()
|
|
for _, id := range queueIDs {
|
|
var attempts int32
|
|
if rerr := w.pool.QueryRow(ctx,
|
|
`SELECT attempts FROM scrobble_queue WHERE id = $1`, id).Scan(&attempts); rerr != nil {
|
|
w.logger.Error("scrobble: read attempts", "err", rerr)
|
|
continue
|
|
}
|
|
if delay, ok := backoffDelay(int(attempts) + 1); ok {
|
|
nextAt := pgtype.Timestamptz{Time: now.Add(delay), Valid: true}
|
|
if uerr := q.RescheduleScrobble(ctx, dbq.RescheduleScrobbleParams{
|
|
ID: id, NextAttemptAt: nextAt, LastError: &errStr,
|
|
}); uerr != nil {
|
|
w.logger.Error("scrobble: RescheduleScrobble", "err", uerr)
|
|
}
|
|
} else {
|
|
if uerr := q.MarkScrobbleFailed(ctx, dbq.MarkScrobbleFailedParams{
|
|
ID: id, LastError: &errStr,
|
|
}); uerr != nil {
|
|
w.logger.Error("scrobble: MarkScrobbleFailed", "err", uerr)
|
|
}
|
|
}
|
|
}
|
|
}
|