From 63b25e65adbdbab761ab2e85eb7421e258b5eb05 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 6 Jun 2026 22:30:41 -0400 Subject: [PATCH] feat(server): similar-artists + per-user artist top-tracks endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/artists/{id}/similar — in-library artists ranked by similarity score (deduped across sources), ArtistRef list with cover + album count. GET /api/artists/{id}/top-tracks — current user's most-played tracks for the artist (skips excluded, quarantine filtered). --- internal/api/api.go | 2 + internal/api/library.go | 79 ++++++++++++++++++++++++++ internal/db/dbq/recommendation.sql.go | 77 +++++++++++++++++++++++++ internal/db/dbq/similarity.sql.go | 71 +++++++++++++++++++++++ internal/db/queries/recommendation.sql | 26 +++++++++ internal/db/queries/similarity.sql | 26 +++++++++ 6 files changed, 281 insertions(+) diff --git a/internal/api/api.go b/internal/api/api.go index 0a700d64..ff425bb9 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -86,6 +86,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/artists", h.handleListArtists) authed.Get("/artists/{id}", h.handleGetArtist) authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks) + authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists) + authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks) authed.Get("/albums/{id}", h.handleGetAlbum) authed.Get("/albums/{id}/cover", h.handleGetCover) authed.Get("/library/shuffle", h.handleLibraryShuffle) diff --git a/internal/api/library.go b/internal/api/library.go index 1bfc7523..cc919a81 100644 --- a/internal/api/library.go +++ b/internal/api/library.go @@ -159,6 +159,85 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, out) } +// similarArtistsDefaultLimit caps the artist-detail "similar artists" strip; +// artistTopTracksDefaultLimit caps the per-user "top tracks" panel. +const ( + similarArtistsDefaultLimit = 12 + artistTopTracksDefaultLimit = 5 +) + +// handleGetSimilarArtists implements GET /api/artists/{id}/similar. Returns +// in-library artists similar to {id} (ranked by similarity score) as a flat +// ArtistRef list with cover + album count. Empty when the similarity ingest +// has no matches yet; 404 when the artist doesn't exist. +func (h *handlers) handleGetSimilarArtists(w http.ResponseWriter, r *http.Request) { + id, ok := requireURLUUID(w, r, "id") + if !ok { + return + } + q := dbq.New(h.pool) + if _, err := q.GetArtistByID(r.Context(), id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, apierror.NotFound("artist")) + return + } + h.logger.Error("api: get artist for similar", "err", err) + writeErr(w, apierror.InternalMsg("lookup failed", err)) + return + } + rows, err := q.ListSimilarArtistsForArtist(r.Context(), dbq.ListSimilarArtistsForArtistParams{ + SeedArtistID: id, ResultLimit: similarArtistsDefaultLimit, + }) + if err != nil { + h.logger.Error("api: list similar artists", "err", err) + writeErr(w, apierror.InternalMsg("lookup failed", err)) + return + } + out := make([]ArtistRef, 0, len(rows)) + for _, row := range rows { + out = append(out, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID)) + } + writeJSON(w, http.StatusOK, out) +} + +// handleGetArtistTopTracks implements GET /api/artists/{id}/top-tracks. Returns +// the current user's most-played tracks for {id} (skips excluded, quarantine +// filtered). Empty when the user hasn't played this artist; 404 when the +// artist doesn't exist. +func (h *handlers) handleGetArtistTopTracks(w http.ResponseWriter, r *http.Request) { + id, ok := requireURLUUID(w, r, "id") + if !ok { + return + } + user, ok := requireUser(w, r) + if !ok { + return + } + q := dbq.New(h.pool) + if _, err := q.GetArtistByID(r.Context(), id); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, apierror.NotFound("artist")) + return + } + h.logger.Error("api: get artist for top tracks", "err", err) + writeErr(w, apierror.InternalMsg("lookup failed", err)) + return + } + rows, err := q.ListMostPlayedTracksForArtist(r.Context(), dbq.ListMostPlayedTracksForArtistParams{ + ArtistID: id, UserID: user.ID, ResultLimit: artistTopTracksDefaultLimit, + }) + if err != nil { + h.logger.Error("api: list artist top tracks", "err", err) + writeErr(w, apierror.InternalMsg("lookup failed", err)) + return + } + out := make([]TrackRef, 0, len(rows)) + for _, row := range rows { + out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName)) + } + writeJSON(w, http.StatusOK, out) +} + // handleLibraryShuffle implements GET /api/library/shuffle?limit=N — // the online source for the client's always-present "Shuffle all" // (#427 S4). N random tracks across the whole library, per-user diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index e78ea599..f508a031 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -97,6 +97,83 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast return items, nil } +const listMostPlayedTracksForArtist = `-- name: ListMostPlayedTracksForArtist :many +WITH plays AS ( + SELECT track_id, count(*) AS cnt + FROM play_events + WHERE user_id = $2 AND was_skipped = false + GROUP BY track_id +) +SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, + albums.title AS album_title, + artists.name AS artist_name +FROM plays p +JOIN tracks t ON t.id = p.track_id +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE t.artist_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $2 AND q.track_id = t.id + ) +ORDER BY p.cnt DESC, t.id +LIMIT $3 +` + +type ListMostPlayedTracksForArtistParams struct { + ArtistID pgtype.UUID + UserID pgtype.UUID + ResultLimit int32 +} + +type ListMostPlayedTracksForArtistRow struct { + Track Track + AlbumTitle string + ArtistName string +} + +// Top tracks for one artist by this user's completed-play count (skips +// excluded, quarantine filtered). Same projection as +// ListMostPlayedTracksForUser plus an artist_id filter, so the handler +// reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName). +func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMostPlayedTracksForArtistParams) ([]ListMostPlayedTracksForArtistRow, error) { + rows, err := q.db.Query(ctx, listMostPlayedTracksForArtist, arg.ArtistID, arg.UserID, arg.ResultLimit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListMostPlayedTracksForArtistRow + for rows.Next() { + var i ListMostPlayedTracksForArtistRow + if err := rows.Scan( + &i.Track.ID, + &i.Track.Title, + &i.Track.AlbumID, + &i.Track.ArtistID, + &i.Track.TrackNumber, + &i.Track.DiscNumber, + &i.Track.DurationMs, + &i.Track.FilePath, + &i.Track.FileSize, + &i.Track.FileFormat, + &i.Track.Bitrate, + &i.Track.Mbid, + &i.Track.Genre, + &i.Track.AddedAt, + &i.Track.UpdatedAt, + &i.AlbumTitle, + &i.ArtistName, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many WITH plays AS ( SELECT track_id, count(*) AS cnt diff --git a/internal/db/dbq/similarity.sql.go b/internal/db/dbq/similarity.sql.go index 5fff165f..153bcb26 100644 --- a/internal/db/dbq/similarity.sql.go +++ b/internal/db/dbq/similarity.sql.go @@ -154,6 +154,77 @@ func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit i return items, nil } +const listSimilarArtistsForArtist = `-- name: ListSimilarArtistsForArtist :many +SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version, + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM ( + SELECT artist_b_id, max(score) AS sim_score + FROM artist_similarity + WHERE artist_a_id = $1 + GROUP BY artist_b_id +) s +JOIN artists ON artists.id = s.artist_b_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY s.sim_score DESC, artists.sort_name +LIMIT $2 +` + +type ListSimilarArtistsForArtistParams struct { + SeedArtistID pgtype.UUID + ResultLimit int32 +} + +type ListSimilarArtistsForArtistRow struct { + Artist Artist + CoverAlbumID pgtype.UUID + AlbumCount int64 +} + +// In-library artists similar to a seed artist, ranked by best similarity +// score across sources (deduped per candidate). cover_album_id + album_count +// mirror ListArtistsAlphaWithCovers so the strip renders identical cards. +func (q *Queries) ListSimilarArtistsForArtist(ctx context.Context, arg ListSimilarArtistsForArtistParams) ([]ListSimilarArtistsForArtistRow, error) { + rows, err := q.db.Query(ctx, listSimilarArtistsForArtist, arg.SeedArtistID, arg.ResultLimit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListSimilarArtistsForArtistRow + for rows.Next() { + var i ListSimilarArtistsForArtistRow + if err := rows.Scan( + &i.Artist.ID, + &i.Artist.Name, + &i.Artist.SortName, + &i.Artist.Mbid, + &i.Artist.CreatedAt, + &i.Artist.UpdatedAt, + &i.Artist.ArtistThumbPath, + &i.Artist.ArtistFanartPath, + &i.Artist.ArtistArtSource, + &i.Artist.ArtistArtSourcesVersion, + &i.CoverAlbumID, + &i.AlbumCount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const upsertArtistSimilarity = `-- name: UpsertArtistSimilarity :exec INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at) VALUES ($1, $2, $3, 'listenbrainz', now()) diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 68299d48..7611daac 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -231,6 +231,32 @@ WHERE NOT EXISTS ( ORDER BY p.cnt DESC, t.id LIMIT $2; +-- name: ListMostPlayedTracksForArtist :many +-- Top tracks for one artist by this user's completed-play count (skips +-- excluded, quarantine filtered). Same projection as +-- ListMostPlayedTracksForUser plus an artist_id filter, so the handler +-- reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName). +WITH plays AS ( + SELECT track_id, count(*) AS cnt + FROM play_events + WHERE user_id = sqlc.arg(user_id) AND was_skipped = false + GROUP BY track_id +) +SELECT sqlc.embed(t), + albums.title AS album_title, + artists.name AS artist_name +FROM plays p +JOIN tracks t ON t.id = p.track_id +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE t.artist_id = sqlc.arg(artist_id) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id + ) +ORDER BY p.cnt DESC, t.id +LIMIT sqlc.arg(result_limit); + -- name: ListLastPlayedArtistsForUser :many -- M6a: artists ranked by max(play_events.started_at) for the user, with -- a derived cover_album_id via a representative-album lateral join (most diff --git a/internal/db/queries/similarity.sql b/internal/db/queries/similarity.sql index c9688c00..94989f94 100644 --- a/internal/db/queries/similarity.sql +++ b/internal/db/queries/similarity.sql @@ -60,3 +60,29 @@ ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET candidate_name = EXCLUDED.candidate_name, score = EXCLUDED.score, fetched_at = now(); + +-- name: ListSimilarArtistsForArtist :many +-- In-library artists similar to a seed artist, ranked by best similarity +-- score across sources (deduped per candidate). cover_album_id + album_count +-- mirror ListArtistsAlphaWithCovers so the strip renders identical cards. +SELECT sqlc.embed(artists), + cov.id AS cover_album_id, + cnt.album_count::bigint AS album_count +FROM ( + SELECT artist_b_id, max(score) AS sim_score + FROM artist_similarity + WHERE artist_a_id = sqlc.arg(seed_artist_id) + GROUP BY artist_b_id +) s +JOIN artists ON artists.id = s.artist_b_id +LEFT JOIN LATERAL ( + SELECT id FROM albums + WHERE artist_id = artists.id AND cover_art_path IS NOT NULL + ORDER BY created_at DESC LIMIT 1 +) cov ON true +LEFT JOIN LATERAL ( + SELECT count(*) AS album_count + FROM albums WHERE artist_id = artists.id +) cnt ON true +ORDER BY s.sim_score DESC, artists.sort_name +LIMIT sqlc.arg(result_limit);