Files
minstrel/internal/lidarrrequests/reconciler.go
T

193 lines
5.0 KiB
Go

package lidarrrequests
import (
"context"
"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/lidarrconfig"
)
// Reconciler is a background worker that periodically scans approved
// lidarr_requests and transitions them to completed when their target
// track/album/artist has appeared in the local library (matched by MBID).
// Errors per-row are logged at WARN and do not abort the tick.
type Reconciler struct {
pool *pgxpool.Pool
lidarrCfg *lidarrconfig.Service
logger *slog.Logger
tick time.Duration
batch int32
}
// NewReconciler constructs a Reconciler with production defaults:
// 5-minute tick, batch size 50.
func NewReconciler(pool *pgxpool.Pool, cfg *lidarrconfig.Service, logger *slog.Logger) *Reconciler {
return &Reconciler{
pool: pool,
lidarrCfg: cfg,
logger: logger,
tick: 5 * time.Minute,
batch: 50,
}
}
// Run blocks until ctx is cancelled, ticking every r.tick. Errors from
// tickOnce are logged at WARN and never propagated.
func (r *Reconciler) Run(ctx context.Context) {
t := time.NewTicker(r.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := r.tickOnce(ctx); err != nil {
r.logger.Warn("lidarrrequests: reconciler tick failed", "err", err)
}
}
}
}
// tickOnce loads approved requests (oldest first, up to r.batch) and
// transitions any whose target MBID is now present in the local library.
// It is exported-for-tests via the lowercase name (package-internal).
func (r *Reconciler) tickOnce(ctx context.Context) error {
cfg, err := r.lidarrCfg.Get(ctx)
if err != nil {
return err
}
if !cfg.Enabled {
r.logger.Debug("lidarrrequests: reconciler skipping tick — lidarr disabled")
return nil
}
q := dbq.New(r.pool)
rows, err := q.ListApprovedLidarrRequestsForReconcile(ctx, r.batch)
if err != nil {
return err
}
for _, row := range rows {
if err := r.reconcileRow(ctx, q, row); err != nil {
r.logger.Warn("lidarrrequests: reconciler row failed",
"request_id", row.ID,
"kind", row.Kind,
"err", err,
)
}
}
return nil
}
// reconcileRow checks the library for a match and, if found, calls
// CompleteLidarrRequest. It returns an error only for unexpected DB
// failures; a no-match is not an error.
func (r *Reconciler) reconcileRow(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
switch row.Kind {
case dbq.LidarrRequestKindArtist:
return r.reconcileArtist(ctx, q, row)
case dbq.LidarrRequestKindAlbum:
return r.reconcileAlbum(ctx, q, row)
case dbq.LidarrRequestKindTrack:
return r.reconcileTrack(ctx, q, row)
default:
r.logger.Warn("lidarrrequests: reconciler unknown kind", "kind", row.Kind)
return nil
}
}
func (r *Reconciler) reconcileArtist(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
var artistID pgtype.UUID
err := r.pool.QueryRow(ctx,
"SELECT id FROM artists WHERE mbid = $1",
row.LidarrArtistMbid,
).Scan(&artistID)
if err != nil {
if isNoRows(err) {
return nil // not in library yet
}
return err
}
_, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedArtistID: artistID,
MatchedAlbumID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
return err
}
func (r *Reconciler) reconcileAlbum(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
if row.LidarrAlbumMbid == nil {
return nil
}
var albumID pgtype.UUID
err := r.pool.QueryRow(ctx,
"SELECT id FROM albums WHERE mbid = $1",
*row.LidarrAlbumMbid,
).Scan(&albumID)
if err != nil {
if isNoRows(err) {
return nil
}
return err
}
_, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedAlbumID: albumID,
MatchedArtistID: pgtype.UUID{},
MatchedTrackID: pgtype.UUID{},
})
return err
}
func (r *Reconciler) reconcileTrack(ctx context.Context, q *dbq.Queries, row dbq.LidarrRequest) error {
if row.LidarrAlbumMbid == nil {
return nil
}
// Track-kind requests match via their parent album's MBID, not track.mbid.
var albumID pgtype.UUID
err := r.pool.QueryRow(ctx,
"SELECT id FROM albums WHERE mbid = $1",
*row.LidarrAlbumMbid,
).Scan(&albumID)
if err != nil {
if isNoRows(err) {
return nil
}
return err
}
// Load any track from that album to set matched_track_id.
var trackID pgtype.UUID
err = r.pool.QueryRow(ctx,
"SELECT id FROM tracks WHERE album_id = $1 ORDER BY id LIMIT 1",
albumID,
).Scan(&trackID)
if err != nil {
if isNoRows(err) {
// Album is in the library but no tracks ingested yet — try again next tick.
return nil
}
return err
}
_, err = q.CompleteLidarrRequest(ctx, dbq.CompleteLidarrRequestParams{
ID: row.ID,
MatchedAlbumID: albumID,
MatchedTrackID: trackID,
MatchedArtistID: pgtype.UUID{},
})
return err
}
func isNoRows(err error) bool {
return err == pgx.ErrNoRows
}