diff --git a/internal/similarity/worker.go b/internal/similarity/worker.go index 6b017e3e..e37cf5be 100644 --- a/internal/similarity/worker.go +++ b/internal/similarity/worker.go @@ -189,9 +189,10 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist } } - taken := 0 + // Matched: in-library similars → artist_similarity (existing path). + takenMatched := 0 for _, r := range results { - if taken >= w.topK { + if takenMatched >= w.topK { break } localID, ok := idByMBID[r.MBID] @@ -207,6 +208,34 @@ func (w *Worker) upsertArtistSimilar(ctx context.Context, q *dbq.Queries, artist w.logger.Warn("similarity: UpsertArtistSimilarity", "err", uerr) continue } - taken++ + takenMatched++ + } + + // Unmatched: out-of-library similars → artist_similarity_unmatched (M5c). + // Same top-K cap as the matched path. Skip rows missing a name — we can't + // render a suggestion card without one. + takenUnmatched := 0 + for _, r := range results { + if takenUnmatched >= w.topK { + break + } + if _, inLib := idByMBID[r.MBID]; inLib { + continue + } + if r.Name == "" { + w.logger.Debug("similarity: skipping unmatched similar with empty name", "mbid", r.MBID) + continue + } + if uerr := q.UpsertArtistSimilarityUnmatched(ctx, dbq.UpsertArtistSimilarityUnmatchedParams{ + SeedArtistID: artistAID, + CandidateMbid: r.MBID, + CandidateName: r.Name, + Score: r.Score, + Source: "listenbrainz", + }); uerr != nil { + w.logger.Warn("similarity: UpsertArtistSimilarityUnmatched", "err", uerr) + continue + } + takenUnmatched++ } } diff --git a/internal/similarity/worker_integration_test.go b/internal/similarity/worker_integration_test.go index 87cf7732..e79863ca 100644 --- a/internal/similarity/worker_integration_test.go +++ b/internal/similarity/worker_integration_test.go @@ -394,3 +394,73 @@ func TestTickOnce_NoMBIDOnTrack_Skipped(t *testing.T) { t.Errorf("no-MBID track produced rows: %d", got) } } + +// TestUpsertArtistSimilar_PersistsUnmatchedToTable: M5c. Calls +// upsertArtistSimilar directly with a mix of in-library and out-of-library +// similar-artist payloads; verifies the matched path lands in +// artist_similarity (1 row), the unmatched path lands in +// artist_similarity_unmatched (3 rows), and rows with empty Name are +// skipped (we can't render a suggestion without a name). +func TestUpsertArtistSimilar_PersistsUnmatchedToTable(t *testing.T) { + f := newFixture(t) + ctx := context.Background() + + // Seed one additional in-library artist that will be the in-library match. + inLibMBID := "in-lib-mbid-123" + inLibArtist, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{ + Name: "InLib Artist", SortName: "InLib Artist", Mbid: &inLibMBID, + }) + if err != nil { + t.Fatalf("seed in-lib artist: %v", err) + } + + w := newTestWorker(f, "") + + similars := []listenbrainz.SimilarArtist{ + {MBID: inLibMBID, Name: "InLib Artist", Score: 0.95}, + {MBID: "out-mbid-1", Name: "Outsider One", Score: 0.85}, + {MBID: "out-mbid-2", Name: "Outsider Two", Score: 0.80}, + {MBID: "out-mbid-3", Name: "Outsider Three", Score: 0.70}, + {MBID: "out-mbid-4", Name: "", Score: 0.60}, // empty name — skipped + } + w.upsertArtistSimilar(ctx, f.q, f.artist.ID, similars) + + // Matched path: 1 row in artist_similarity for the in-library candidate. + var matchedCount int + if err := f.pool.QueryRow(ctx, + "SELECT count(*) FROM artist_similarity WHERE artist_a_id = $1", + f.artist.ID, + ).Scan(&matchedCount); err != nil { + t.Fatalf("count matched: %v", err) + } + if matchedCount != 1 { + t.Errorf("artist_similarity rows = %d, want 1 (only the in-library match)", matchedCount) + } + + // Unmatched path: 3 rows (out-mbid-1/2/3); the empty-name row is skipped. + var unmatchedCount int + if err := f.pool.QueryRow(ctx, + "SELECT count(*) FROM artist_similarity_unmatched WHERE seed_artist_id = $1", + f.artist.ID, + ).Scan(&unmatchedCount); err != nil { + t.Fatalf("count unmatched: %v", err) + } + if unmatchedCount != 3 { + t.Errorf("artist_similarity_unmatched rows = %d, want 3", unmatchedCount) + } + + // Verify a specific row's name + score round-tripped correctly. + var name string + var score float64 + if err := f.pool.QueryRow(ctx, + "SELECT candidate_name, score FROM artist_similarity_unmatched WHERE seed_artist_id = $1 AND candidate_mbid = $2", + f.artist.ID, "out-mbid-1", + ).Scan(&name, &score); err != nil { + t.Fatalf("fetch out-mbid-1: %v", err) + } + if name != "Outsider One" || score != 0.85 { + t.Errorf("row = (%q, %v), want (Outsider One, 0.85)", name, score) + } + + _ = inLibArtist +}