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 }