feat(similarity): implement Worker.tickOnce with track + artist passes
Replace stub with full tickOnce: drains played tracks/artists via ListPlayedTracksNeedingSimilarity / ListPlayedArtistsNeedingSimilarity, calls LB SimilarRecordings/SimilarArtists, filters to local library via bulk MBID lookup, enforces top-K=20 by score, and upserts similarity rows. 429 aborts the entire tick; other errors log-and-skip. Ten integration tests covering no-op, library filtering, top-K cap, 7-day freshness cap, stale refresh, 429 abort, transient skip, and artist pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,15 @@ package similarity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -58,9 +62,151 @@ func (w *Worker) Run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// tickOnce drains one batch of tracks and one batch of artists. Stub —
|
||||
// implementation lands in Task 6 along with the integration tests that
|
||||
// drive it.
|
||||
func (w *Worker) tickOnce(_ context.Context) error {
|
||||
// tickOnce drains one batch of tracks and one batch of artists. Per-row
|
||||
// errors are logged and skipped (passive retry via timer). 429 aborts the
|
||||
// entire tick.
|
||||
func (w *Worker) tickOnce(ctx context.Context) error {
|
||||
q := dbq.New(w.pool)
|
||||
if err := w.tickTracks(ctx, q); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.tickArtists(ctx, q)
|
||||
}
|
||||
|
||||
func (w *Worker) tickTracks(ctx context.Context, q *dbq.Queries) error {
|
||||
rows, err := q.ListPlayedTracksNeedingSimilarity(ctx, w.batch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Mbid == nil {
|
||||
continue // defensive — query already filters NULL
|
||||
}
|
||||
results, err := w.client.SimilarRecordings(ctx, *r.Mbid, 100)
|
||||
if err != nil {
|
||||
var ra *listenbrainz.RetryAfterError
|
||||
if errors.As(err, &ra) {
|
||||
w.logger.Warn("similarity: 429 — aborting tick", "retry_after", ra.Wait)
|
||||
return nil
|
||||
}
|
||||
w.logger.Warn("similarity: similar-recordings failed", "track_id", r.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
w.upsertTrackSimilar(ctx, q, r.ID, results)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) tickArtists(ctx context.Context, q *dbq.Queries) error {
|
||||
rows, err := q.ListPlayedArtistsNeedingSimilarity(ctx, w.batch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Mbid == nil {
|
||||
continue
|
||||
}
|
||||
results, err := w.client.SimilarArtists(ctx, *r.Mbid, 100)
|
||||
if err != nil {
|
||||
var ra *listenbrainz.RetryAfterError
|
||||
if errors.As(err, &ra) {
|
||||
w.logger.Warn("similarity: 429 — aborting tick", "retry_after", ra.Wait)
|
||||
return nil
|
||||
}
|
||||
w.logger.Warn("similarity: similar-artists failed", "artist_id", r.ID, "err", err)
|
||||
continue
|
||||
}
|
||||
w.upsertArtistSimilar(ctx, q, r.ID, results)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// upsertTrackSimilar filters returned MBIDs to those in our library, takes
|
||||
// top-K by score, and upserts rows.
|
||||
func (w *Worker) upsertTrackSimilar(ctx context.Context, q *dbq.Queries, trackAID pgtype.UUID, results []listenbrainz.SimilarRecording) {
|
||||
if len(results) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score })
|
||||
|
||||
mbids := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
mbids = append(mbids, r.MBID)
|
||||
}
|
||||
rows, err := q.GetTracksByMBIDs(ctx, mbids)
|
||||
if err != nil {
|
||||
w.logger.Warn("similarity: GetTracksByMBIDs", "err", err)
|
||||
return
|
||||
}
|
||||
idByMBID := make(map[string]pgtype.UUID, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.Mbid != nil {
|
||||
idByMBID[*r.Mbid] = r.ID
|
||||
}
|
||||
}
|
||||
|
||||
taken := 0
|
||||
for _, r := range results {
|
||||
if taken >= w.topK {
|
||||
break
|
||||
}
|
||||
localID, ok := idByMBID[r.MBID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if localID == trackAID {
|
||||
continue // defensive — DB CHECK constraint also catches self-edges
|
||||
}
|
||||
if uerr := q.UpsertTrackSimilarity(ctx, dbq.UpsertTrackSimilarityParams{
|
||||
TrackAID: trackAID, TrackBID: localID, Score: r.Score,
|
||||
}); uerr != nil {
|
||||
w.logger.Warn("similarity: UpsertTrackSimilarity", "err", uerr)
|
||||
continue
|
||||
}
|
||||
taken++
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artistAID pgtype.UUID, results []listenbrainz.SimilarArtist) {
|
||||
if len(results) == 0 {
|
||||
return
|
||||
}
|
||||
sort.Slice(results, func(i, j int) bool { return results[i].Score > results[j].Score })
|
||||
|
||||
mbids := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
mbids = append(mbids, r.MBID)
|
||||
}
|
||||
rows, err := q.GetArtistsByMBIDs(ctx, mbids)
|
||||
if err != nil {
|
||||
w.logger.Warn("similarity: GetArtistsByMBIDs", "err", err)
|
||||
return
|
||||
}
|
||||
idByMBID := make(map[string]pgtype.UUID, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.Mbid != nil {
|
||||
idByMBID[*r.Mbid] = r.ID
|
||||
}
|
||||
}
|
||||
|
||||
taken := 0
|
||||
for _, r := range results {
|
||||
if taken >= w.topK {
|
||||
break
|
||||
}
|
||||
localID, ok := idByMBID[r.MBID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if localID == artistAID {
|
||||
continue
|
||||
}
|
||||
if uerr := q.UpsertArtistSimilarity(ctx, dbq.UpsertArtistSimilarityParams{
|
||||
ArtistAID: artistAID, ArtistBID: localID, Score: r.Score,
|
||||
}); uerr != nil {
|
||||
w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr)
|
||||
continue
|
||||
}
|
||||
taken++
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user