feat(recommendation): SuggestArtists service for M5c
Add per-user artist-suggestion service ranking out-of-library MBIDs by signal x similarity. Single-CTE SQL collects user likes (5x weight) and recency-decayed plays, joins against artist_similarity_unmatched, and filters in-library candidates plus non-terminal lidarr_requests. The service resolves top-3 attribution seeds to artist names in a batched GetArtistsByIDs call so the UI can render "because you liked X" reasons. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,39 @@ func (q *Queries) GetArtistByName(ctx context.Context, name string) (Artist, err
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getArtistsByIDs = `-- name: GetArtistsByIDs :many
|
||||
SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists WHERE id = ANY($1::uuid[])
|
||||
`
|
||||
|
||||
// Batched lookup used by M5c suggestion attribution to resolve top-3
|
||||
// contributing seed UUIDs back to artist names in one round-trip.
|
||||
func (q *Queries) GetArtistsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Artist, error) {
|
||||
rows, err := q.db.Query(ctx, getArtistsByIDs, dollar_1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Artist
|
||||
for rows.Next() {
|
||||
var i Artist
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Name,
|
||||
&i.SortName,
|
||||
&i.Mbid,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listArtists = `-- name: ListArtists :many
|
||||
SELECT id, name, sort_name, mbid, created_at, updated_at FROM artists ORDER BY sort_name
|
||||
`
|
||||
|
||||
@@ -294,3 +294,99 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
||||
WITH seeds AS (
|
||||
SELECT a.id AS artist_id,
|
||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||
AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COUNT(pe.id)::bigint AS play_count
|
||||
FROM artists a
|
||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
||||
GROUP BY a.id, gla.artist_id
|
||||
),
|
||||
contributions AS (
|
||||
SELECT u.candidate_mbid,
|
||||
u.candidate_name,
|
||||
seeds.artist_id AS seed_id,
|
||||
seeds.is_liked,
|
||||
seeds.play_count,
|
||||
seeds.signal * u.score AS contribution
|
||||
FROM artist_similarity_unmatched u
|
||||
JOIN seeds ON seeds.artist_id = u.seed_artist_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_requests r
|
||||
WHERE r.user_id = $1
|
||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||
AND r.status NOT IN ('rejected', 'failed')
|
||||
)
|
||||
)
|
||||
SELECT candidate_mbid,
|
||||
candidate_name,
|
||||
SUM(contribution)::float8 AS total_score,
|
||||
((array_agg(seed_id ORDER BY contribution DESC))[1:3])::uuid[] AS top_seed_ids,
|
||||
((array_agg(contribution ORDER BY contribution DESC))[1:3])::float8[] AS top_contributions,
|
||||
((array_agg(is_liked ORDER BY contribution DESC))[1:3])::boolean[] AS top_is_liked,
|
||||
((array_agg(play_count ORDER BY contribution DESC))[1:3])::bigint[] AS top_play_counts
|
||||
FROM contributions
|
||||
GROUP BY candidate_mbid, candidate_name
|
||||
ORDER BY total_score DESC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type SuggestArtistsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Column2 float64
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type SuggestArtistsForUserRow struct {
|
||||
CandidateMbid string
|
||||
CandidateName string
|
||||
TotalScore float64
|
||||
TopSeedIds []pgtype.UUID
|
||||
TopContributions []float64
|
||||
TopIsLiked []bool
|
||||
TopPlayCounts []int64
|
||||
}
|
||||
|
||||
// M5c: per-user artist suggestions ranked by signal x similarity. The
|
||||
// seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
||||
// (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
||||
// artist_similarity_unmatched and filters out candidates already in
|
||||
// library or already in a non-terminal lidarr_request. The outer SELECT
|
||||
// aggregates per candidate, returning the top-3 contributing seeds for
|
||||
// attribution. $1=user_id, $2=half_life_days, $3=limit.
|
||||
func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []SuggestArtistsForUserRow
|
||||
for rows.Next() {
|
||||
var i SuggestArtistsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.CandidateMbid,
|
||||
&i.CandidateName,
|
||||
&i.TotalScore,
|
||||
&i.TopSeedIds,
|
||||
&i.TopContributions,
|
||||
&i.TopIsLiked,
|
||||
&i.TopPlayCounts,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
@@ -37,3 +37,8 @@ SELECT COUNT(*) FROM artists;
|
||||
|
||||
-- name: CountArtistsMatching :one
|
||||
SELECT COUNT(*) FROM artists WHERE name ILIKE '%' || $1::text || '%';
|
||||
|
||||
-- name: GetArtistsByIDs :many
|
||||
-- Batched lookup used by M5c suggestion attribution to resolve top-3
|
||||
-- contributing seed UUIDs back to artist names in one round-trip.
|
||||
SELECT * FROM artists WHERE id = ANY($1::uuid[]);
|
||||
|
||||
@@ -150,3 +150,54 @@ GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
|
||||
|
||||
-- name: SuggestArtistsForUser :many
|
||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
||||
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
||||
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
||||
-- artist_similarity_unmatched and filters out candidates already in
|
||||
-- library or already in a non-terminal lidarr_request. The outer SELECT
|
||||
-- aggregates per candidate, returning the top-3 contributing seeds for
|
||||
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
|
||||
WITH seeds AS (
|
||||
SELECT a.id AS artist_id,
|
||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||
AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COUNT(pe.id)::bigint AS play_count
|
||||
FROM artists a
|
||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
||||
GROUP BY a.id, gla.artist_id
|
||||
),
|
||||
contributions AS (
|
||||
SELECT u.candidate_mbid,
|
||||
u.candidate_name,
|
||||
seeds.artist_id AS seed_id,
|
||||
seeds.is_liked,
|
||||
seeds.play_count,
|
||||
seeds.signal * u.score AS contribution
|
||||
FROM artist_similarity_unmatched u
|
||||
JOIN seeds ON seeds.artist_id = u.seed_artist_id
|
||||
WHERE NOT EXISTS (SELECT 1 FROM artists WHERE mbid = u.candidate_mbid)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_requests r
|
||||
WHERE r.user_id = $1
|
||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||
AND r.status NOT IN ('rejected', 'failed')
|
||||
)
|
||||
)
|
||||
SELECT candidate_mbid,
|
||||
candidate_name,
|
||||
SUM(contribution)::float8 AS total_score,
|
||||
((array_agg(seed_id ORDER BY contribution DESC))[1:3])::uuid[] AS top_seed_ids,
|
||||
((array_agg(contribution ORDER BY contribution DESC))[1:3])::float8[] AS top_contributions,
|
||||
((array_agg(is_liked ORDER BY contribution DESC))[1:3])::boolean[] AS top_is_liked,
|
||||
((array_agg(play_count ORDER BY contribution DESC))[1:3])::bigint[] AS top_play_counts
|
||||
FROM contributions
|
||||
GROUP BY candidate_mbid, candidate_name
|
||||
ORDER BY total_score DESC
|
||||
LIMIT $3;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// suggestions.go is the M5c per-user artist-suggestion service. Reads
|
||||
// the user's likes + plays, projects them through artist_similarity_unmatched
|
||||
// via a single CTE, returns top-N candidates with top-3 attribution seeds
|
||||
// resolved to artist names.
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// ArtistSuggestion is one ranked candidate with its top-3 attribution seeds.
|
||||
type ArtistSuggestion struct {
|
||||
MBID string
|
||||
Name string
|
||||
Score float64
|
||||
Attribution []SeedContribution
|
||||
}
|
||||
|
||||
// SeedContribution is one of the top-3 contributing seeds for a candidate.
|
||||
type SeedContribution struct {
|
||||
ArtistID pgtype.UUID
|
||||
Name string
|
||||
Contribution float64
|
||||
IsLiked bool
|
||||
PlayCount int64
|
||||
}
|
||||
|
||||
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
||||
// capped at 50 (default 12 when out of range); halfLifeDays is the
|
||||
// recency-decay half-life for plays (default 30, operator-tunable).
|
||||
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
|
||||
if limit <= 0 || limit > 50 {
|
||||
limit = 12
|
||||
}
|
||||
if halfLifeDays <= 0 {
|
||||
halfLifeDays = 30
|
||||
}
|
||||
q := dbq.New(pool)
|
||||
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
|
||||
UserID: userID,
|
||||
Column2: halfLifeDays,
|
||||
Limit: int32(limit),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: query: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return []ArtistSuggestion{}, nil
|
||||
}
|
||||
|
||||
// Collect the union of top-3 seed IDs across all rows for one batched
|
||||
// name lookup. pgtype.UUID is a comparable struct so it works as a map
|
||||
// key directly.
|
||||
seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3)
|
||||
for _, r := range rows {
|
||||
for _, sid := range r.TopSeedIds {
|
||||
seedSet[sid] = struct{}{}
|
||||
}
|
||||
}
|
||||
seedIDs := make([]pgtype.UUID, 0, len(seedSet))
|
||||
for id := range seedSet {
|
||||
seedIDs = append(seedIDs, id)
|
||||
}
|
||||
artists, err := q.GetArtistsByIDs(ctx, seedIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: resolve seeds: %w", err)
|
||||
}
|
||||
nameByID := make(map[pgtype.UUID]string, len(artists))
|
||||
for _, a := range artists {
|
||||
nameByID[a.ID] = a.Name
|
||||
}
|
||||
|
||||
out := make([]ArtistSuggestion, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
attribution := make([]SeedContribution, 0, len(r.TopSeedIds))
|
||||
for i, sid := range r.TopSeedIds {
|
||||
if i >= len(r.TopContributions) || i >= len(r.TopIsLiked) || i >= len(r.TopPlayCounts) {
|
||||
break
|
||||
}
|
||||
attribution = append(attribution, SeedContribution{
|
||||
ArtistID: sid,
|
||||
Name: nameByID[sid],
|
||||
Contribution: r.TopContributions[i],
|
||||
IsLiked: r.TopIsLiked[i],
|
||||
PlayCount: r.TopPlayCounts[i],
|
||||
})
|
||||
}
|
||||
out = append(out, ArtistSuggestion{
|
||||
MBID: r.CandidateMbid,
|
||||
Name: r.CandidateName,
|
||||
Score: r.TotalScore,
|
||||
Attribution: attribution,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
)
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
dbtest.ResetDB(t, pool)
|
||||
return pool
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||||
t.Helper()
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||||
ApiToken: name + "-token", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func seedArtist(t *testing.T, pool *pgxpool.Pool, name, mbid string) dbq.Artist {
|
||||
t.Helper()
|
||||
var mbidPtr *string
|
||||
if mbid != "" {
|
||||
mbidPtr = &mbid
|
||||
}
|
||||
a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||||
Name: name, SortName: name, Mbid: mbidPtr,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed artist: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, title string) dbq.Album {
|
||||
t.Helper()
|
||||
a, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||
Title: title, SortTitle: title, ArtistID: artistID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed album: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func seedTrackOnAlbum(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string) dbq.Track {
|
||||
t.Helper()
|
||||
tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||
Title: title, AlbumID: albumID, ArtistID: artistID,
|
||||
DurationMs: 1000, FilePath: "/tmp/m5c-" + title + ".mp3",
|
||||
FileSize: 1, FileFormat: "mp3",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed track: %v", err)
|
||||
}
|
||||
return tr
|
||||
}
|
||||
|
||||
func insertPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var sessionID pgtype.UUID
|
||||
if err := pool.QueryRow(ctx,
|
||||
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||
VALUES ($1, $2, $2, 'm5c-test') RETURNING id`,
|
||||
userID, startedAt,
|
||||
).Scan(&sessionID); err != nil {
|
||||
t.Fatalf("insert play_session: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
|
||||
VALUES ($1, $2, $3, $4, false)`,
|
||||
userID, trackID, sessionID, startedAt,
|
||||
); err != nil {
|
||||
t.Fatalf("insert play_event: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
|
||||
userID, artistID,
|
||||
); err != nil {
|
||||
t.Fatalf("like artist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func seedUnmatched(t *testing.T, pool *pgxpool.Pool, seedID pgtype.UUID, candMBID, candName string, score float64) {
|
||||
t.Helper()
|
||||
if err := dbq.New(pool).UpsertArtistSimilarityUnmatched(context.Background(), dbq.UpsertArtistSimilarityUnmatchedParams{
|
||||
SeedArtistID: seedID,
|
||||
CandidateMbid: candMBID,
|
||||
CandidateName: candName,
|
||||
Score: score,
|
||||
Source: "listenbrainz",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed unmatched: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
seedA := seedArtist(t, pool, "Seed Liked", "")
|
||||
seedB := seedArtist(t, pool, "Seed Played", "")
|
||||
|
||||
likeArtist(t, pool, user.ID, seedA.ID)
|
||||
|
||||
seedBAlbum := seedAlbumForArtist(t, pool, seedB.ID, "Album B")
|
||||
seedBTrack := seedTrackOnAlbum(t, pool, seedBAlbum.ID, seedB.ID, "Track B")
|
||||
insertPlayEvent(t, pool, user.ID, seedBTrack.ID, time.Now().Add(-1*time.Hour))
|
||||
|
||||
seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
|
||||
seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(out))
|
||||
}
|
||||
s := out[0]
|
||||
if s.MBID != "out-mbid" || s.Name != "Outsider" {
|
||||
t.Errorf("got = %+v", s)
|
||||
}
|
||||
if s.Score <= 0 {
|
||||
t.Errorf("score = %v, want > 0", s.Score)
|
||||
}
|
||||
if len(s.Attribution) != 2 {
|
||||
t.Errorf("attribution len = %d, want 2", len(s.Attribution))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestArtists_Top12Cap(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
seed := seedArtist(t, pool, "Seed", "")
|
||||
likeArtist(t, pool, user.ID, seed.ID)
|
||||
for i := 0; i < 30; i++ {
|
||||
seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
|
||||
}
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 12 {
|
||||
t.Errorf("len = %d, want 12", len(out))
|
||||
}
|
||||
if out[0].MBID != "mbid-00" {
|
||||
t.Errorf("first = %s, want mbid-00 (highest score)", out[0].MBID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestArtists_AttributionTopThree(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
seeds := make([]dbq.Artist, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
seeds[i] = seedArtist(t, pool, fmt.Sprintf("Seed %d", i), "")
|
||||
likeArtist(t, pool, user.ID, seeds[i].ID)
|
||||
seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
|
||||
}
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len = %d, want 1 (shared candidate)", len(out))
|
||||
}
|
||||
if got := len(out[0].Attribution); got != 3 {
|
||||
t.Errorf("attribution len = %d, want 3", got)
|
||||
}
|
||||
if out[0].Attribution[0].Name != "Seed 0" {
|
||||
t.Errorf("top attribution = %q, want Seed 0", out[0].Attribution[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
recentSeed := seedArtist(t, pool, "Recent", "")
|
||||
oldSeed := seedArtist(t, pool, "Old", "")
|
||||
|
||||
rAlbum := seedAlbumForArtist(t, pool, recentSeed.ID, "Recent Album")
|
||||
rTrack := seedTrackOnAlbum(t, pool, rAlbum.ID, recentSeed.ID, "Recent Track")
|
||||
insertPlayEvent(t, pool, user.ID, rTrack.ID, time.Now().Add(-1*24*time.Hour))
|
||||
|
||||
oAlbum := seedAlbumForArtist(t, pool, oldSeed.ID, "Old Album")
|
||||
oTrack := seedTrackOnAlbum(t, pool, oAlbum.ID, oldSeed.ID, "Old Track")
|
||||
insertPlayEvent(t, pool, user.ID, oTrack.ID, time.Now().Add(-90*24*time.Hour))
|
||||
|
||||
seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
|
||||
seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("len = %d, want 1", len(out))
|
||||
}
|
||||
if len(out[0].Attribution) != 2 {
|
||||
t.Fatalf("attribution len = %d, want 2", len(out[0].Attribution))
|
||||
}
|
||||
if out[0].Attribution[0].Name != "Recent" {
|
||||
t.Errorf("top attribution = %q, want Recent", out[0].Attribution[0].Name)
|
||||
}
|
||||
if out[0].Attribution[0].Contribution <= out[0].Attribution[1].Contribution {
|
||||
t.Errorf("recent contribution (%v) should exceed old (%v)",
|
||||
out[0].Attribution[0].Contribution, out[0].Attribution[1].Contribution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
seed := seedArtist(t, pool, "Seed", "")
|
||||
likeArtist(t, pool, user.ID, seed.ID)
|
||||
inLibMBID := "in-lib-mbid"
|
||||
seedArtist(t, pool, "InLib", inLibMBID)
|
||||
seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("len = %d, want 0 (in-library candidate should be filtered)", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
seed := seedArtist(t, pool, "Seed", "")
|
||||
likeArtist(t, pool, user.ID, seed.ID)
|
||||
seedUnmatched(t, pool, seed.ID, "req-mbid", "Pending Request", 0.9)
|
||||
if _, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
|
||||
UserID: user.ID,
|
||||
Kind: dbq.LidarrRequestKindArtist,
|
||||
LidarrArtistMbid: "req-mbid",
|
||||
ArtistName: "Pending Request",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateLidarrRequest: %v", err)
|
||||
}
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("len = %d, want 0 (pending request should hide candidate)", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
seed := seedArtist(t, pool, "Seed", "")
|
||||
likeArtist(t, pool, user.ID, seed.ID)
|
||||
seedUnmatched(t, pool, seed.ID, "rej-mbid", "Rejected Once", 0.9)
|
||||
req, err := dbq.New(pool).CreateLidarrRequest(context.Background(), dbq.CreateLidarrRequestParams{
|
||||
UserID: user.ID,
|
||||
Kind: dbq.LidarrRequestKindArtist,
|
||||
LidarrArtistMbid: "rej-mbid",
|
||||
ArtistName: "Rejected Once",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateLidarrRequest: %v", err)
|
||||
}
|
||||
rejNotes := "wrong artist"
|
||||
if _, err := dbq.New(pool).RejectLidarrRequest(context.Background(), dbq.RejectLidarrRequestParams{
|
||||
ID: req.ID, Notes: &rejNotes, DecidedBy: user.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("RejectLidarrRequest: %v", err)
|
||||
}
|
||||
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Errorf("len = %d, want 1 (rejected requests don't hide the candidate)", len(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "newbie")
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
if len(out) != 0 {
|
||||
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user