The new codegen check failed on its first run, against the slice-1 hand-edit, which is precisely why it landed on its own commit. What I got wrong: sqlc does not embed the leading `--` header block in the SQL const. It strips those lines and promotes them to the generated method's Go doc comment, gofmt-formatted — blank `//` separators around the indented list, tabs for the indent. My hand-edit left the header inside the string AND left the stale M5c doc comment sitting on the function, so the generated file described behaviour the query no longer had. Comments *inside* the statement body are kept as-is; only the header block moves. Worth knowing before slices 5 and 6 add more queries. Taken verbatim from the diff the check printed, which is the reason it prints before asserting. Round-trip cost: one CI run, no guessing. Note the integration lane passed on the previous push even with the wrong generated file — the SQL text was valid and the signature was unchanged, so executing it against real Postgres proved nothing about whether the committed Go matched its source. That gap is exactly what #2380 closes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1159 lines
38 KiB
Go
1159 lines
38 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
||
// versions:
|
||
// sqlc v1.31.1
|
||
// source: recommendation.sql
|
||
|
||
package dbq
|
||
|
||
import (
|
||
"context"
|
||
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
)
|
||
|
||
const getLatestPlayDeviceClassForUser = `-- name: GetLatestPlayDeviceClassForUser :one
|
||
SELECT device_class
|
||
FROM play_events
|
||
WHERE user_id = $1
|
||
ORDER BY started_at DESC
|
||
LIMIT 1
|
||
`
|
||
|
||
// The device_class of the user's most recent play (#1551), used as the
|
||
// "current device" for radio context conditioning. NULL when the latest play
|
||
// predates device capture or came from a client that didn't send one.
|
||
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
|
||
row := q.db.QueryRow(ctx, getLatestPlayDeviceClassForUser, userID)
|
||
var device_class *string
|
||
err := row.Scan(&device_class)
|
||
return device_class, err
|
||
}
|
||
|
||
const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many
|
||
WITH tz AS (
|
||
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
|
||
FROM users u WHERE u.id = $1
|
||
),
|
||
now_cell AS (
|
||
SELECT
|
||
CASE
|
||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
|
||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
|
||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
|
||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
|
||
ELSE 3
|
||
END AS daypart,
|
||
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||
FROM tz
|
||
),
|
||
plays AS (
|
||
SELECT t.artist_id,
|
||
pe.device_class,
|
||
CASE
|
||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
|
||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
|
||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
|
||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
|
||
ELSE 3
|
||
END AS daypart,
|
||
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||
FROM play_events pe
|
||
JOIN tracks t ON t.id = pe.track_id
|
||
CROSS JOIN tz
|
||
WHERE pe.user_id = $1
|
||
AND pe.was_skipped = false
|
||
AND pe.started_at > now() - interval '365 days'
|
||
)
|
||
SELECT p.artist_id,
|
||
count(*) AS total_plays,
|
||
count(*) FILTER (
|
||
WHERE p.daypart = (SELECT daypart FROM now_cell)
|
||
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
|
||
AND ($2::text = '' OR p.device_class = $2::text)
|
||
) AS cell_plays
|
||
FROM plays p
|
||
GROUP BY p.artist_id
|
||
`
|
||
|
||
type ListArtistContextPlayCountsForUserParams struct {
|
||
ID pgtype.UUID
|
||
Column2 string
|
||
}
|
||
|
||
type ListArtistContextPlayCountsForUserRow struct {
|
||
ArtistID pgtype.UUID
|
||
TotalPlays int64
|
||
CellPlays int64
|
||
}
|
||
|
||
// Per-artist completed-play counts split by whether each play falls in the
|
||
// CURRENT context cell, in the user's local timezone (#1531). The cell is
|
||
// daypart × weekday-type, optionally narrowed by device class (#1551): when
|
||
// $2 (the current device) is ”, the device dimension is ignored (identical to
|
||
// the time-only #1531 behaviour, used by the daily mixes which have no device);
|
||
// when $2 is set (radio), a play only counts in the cell if its device_class
|
||
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
|
||
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
|
||
// afternoon [12,17) evening [17,22). Weekend = ISO days 6–7 (Sat/Sun).
|
||
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) {
|
||
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var items []ListArtistContextPlayCountsForUserRow
|
||
for rows.Next() {
|
||
var i ListArtistContextPlayCountsForUserRow
|
||
if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, i)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
||
WITH user_plays AS (
|
||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||
FROM play_events pe
|
||
JOIN tracks t ON t.id = pe.track_id
|
||
WHERE pe.user_id = $1
|
||
GROUP BY t.artist_id
|
||
)
|
||
SELECT a.id, a.name, a.sort_name, a.mbid, a.created_at, a.updated_at, a.artist_thumb_path, a.artist_fanart_path, a.artist_art_source, a.artist_art_sources_version,
|
||
cov.id AS cover_album_id,
|
||
cnt.album_count::bigint AS album_count,
|
||
up.last_started::timestamptz AS last_played_at
|
||
FROM user_plays up
|
||
JOIN artists a ON a.id = up.artist_id
|
||
LEFT JOIN LATERAL (
|
||
SELECT id FROM albums
|
||
WHERE artist_id = a.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 = a.id
|
||
) cnt ON true
|
||
ORDER BY up.last_started DESC, a.id
|
||
LIMIT $2
|
||
`
|
||
|
||
type ListLastPlayedArtistsForUserParams struct {
|
||
UserID pgtype.UUID
|
||
Limit int32
|
||
}
|
||
|
||
type ListLastPlayedArtistsForUserRow struct {
|
||
Artist Artist
|
||
CoverAlbumID pgtype.UUID
|
||
AlbumCount int64
|
||
LastPlayedAt pgtype.Timestamptz
|
||
}
|
||
|
||
// 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
|
||
// recent album that has cover_art_path set). album_count joined for the
|
||
// ArtistRef wire shape.
|
||
//
|
||
// Earlier shape iterated `FROM artists a` and ran a LATERAL play_events
|
||
// subquery per artist — O(total_artists) plan even for users with a
|
||
// handful of plays. New shape aggregates the user's plays by artist
|
||
// via the play_events → tracks join up front (uses
|
||
// play_events_user_track_idx + tracks pkey lookups), then attaches
|
||
// the artist row and lateral cover/count subqueries only for the
|
||
// artists that actually appear. Distinct-artists set is small for a
|
||
// typical user, so cost is bounded by play history not library size.
|
||
func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLastPlayedArtistsForUserParams) ([]ListLastPlayedArtistsForUserRow, error) {
|
||
rows, err := q.db.Query(ctx, listLastPlayedArtistsForUser, arg.UserID, arg.Limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var items []ListLastPlayedArtistsForUserRow
|
||
for rows.Next() {
|
||
var i ListLastPlayedArtistsForUserRow
|
||
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,
|
||
&i.LastPlayedAt,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, i)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
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, t.tag_source, t.tag_sources_version,
|
||
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.Track.TagSource,
|
||
&i.Track.TagSourcesVersion,
|
||
&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
|
||
FROM play_events
|
||
WHERE user_id = $1 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, t.tag_source, t.tag_sources_version,
|
||
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 NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||
)
|
||
ORDER BY p.cnt DESC, t.id
|
||
LIMIT $2
|
||
`
|
||
|
||
type ListMostPlayedTracksForUserParams struct {
|
||
UserID pgtype.UUID
|
||
Limit int32
|
||
}
|
||
|
||
type ListMostPlayedTracksForUserRow struct {
|
||
Track Track
|
||
AlbumTitle string
|
||
ArtistName string
|
||
}
|
||
|
||
// M6a: top-N tracks by completed-play count for the user. was_skipped
|
||
// excludes skips so a user spamming next doesn't fabricate a top track.
|
||
// Quarantined tracks (per-user soft-hide from M5b) are filtered out.
|
||
//
|
||
// Aggregate play_events first (uses play_events_user_track_idx) and
|
||
// join through tracks/albums/artists only for the survivors. Earlier
|
||
// shape did the join across every play_event row before grouping —
|
||
// O(plays) instead of O(distinct tracks).
|
||
func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostPlayedTracksForUserParams) ([]ListMostPlayedTracksForUserRow, error) {
|
||
rows, err := q.db.Query(ctx, listMostPlayedTracksForUser, arg.UserID, arg.Limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var items []ListMostPlayedTracksForUserRow
|
||
for rows.Next() {
|
||
var i ListMostPlayedTracksForUserRow
|
||
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.Track.TagSource,
|
||
&i.Track.TagSourcesVersion,
|
||
&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 listRediscoverAlbumsFallbackForUser = `-- name: ListRediscoverAlbumsFallbackForUser :many
|
||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||
FROM general_likes_albums gla
|
||
JOIN albums ON albums.id = gla.album_id
|
||
JOIN artists ON artists.id = albums.artist_id
|
||
WHERE gla.user_id = $1
|
||
ORDER BY md5(albums.id::text || current_date::text)
|
||
LIMIT $2
|
||
`
|
||
|
||
type ListRediscoverAlbumsFallbackForUserParams struct {
|
||
UserID pgtype.UUID
|
||
Limit int32
|
||
}
|
||
|
||
type ListRediscoverAlbumsFallbackForUserRow struct {
|
||
Album Album
|
||
ArtistName string
|
||
}
|
||
|
||
// Daily-stable random sample of liked albums for the user. The Go
|
||
// service uses this to top up the rediscover row when the primary
|
||
// eligibility query returns fewer than the inner limit. Same daily
|
||
// hash ordering as the primary so the fallback rows stay stable
|
||
// through the day instead of reshuffling on every refresh. Hash
|
||
// omits user_id so sqlc can keep the user_id parameter UUID-typed
|
||
// (a $1::text cast would force the param to string); eligibility
|
||
// already differs per user, so output sets differ regardless.
|
||
func (q *Queries) ListRediscoverAlbumsFallbackForUser(ctx context.Context, arg ListRediscoverAlbumsFallbackForUserParams) ([]ListRediscoverAlbumsFallbackForUserRow, error) {
|
||
rows, err := q.db.Query(ctx, listRediscoverAlbumsFallbackForUser, arg.UserID, arg.Limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var items []ListRediscoverAlbumsFallbackForUserRow
|
||
for rows.Next() {
|
||
var i ListRediscoverAlbumsFallbackForUserRow
|
||
if err := rows.Scan(
|
||
&i.Album.ID,
|
||
&i.Album.Title,
|
||
&i.Album.SortTitle,
|
||
&i.Album.ArtistID,
|
||
&i.Album.ReleaseDate,
|
||
&i.Album.Mbid,
|
||
&i.Album.CoverArtPath,
|
||
&i.Album.CreatedAt,
|
||
&i.Album.UpdatedAt,
|
||
&i.Album.CoverArtSource,
|
||
&i.Album.CoverArtSourcesVersion,
|
||
&i.ArtistName,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, i)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
const listRediscoverAlbumsForUser = `-- name: ListRediscoverAlbumsForUser :many
|
||
WITH liked_signal AS (
|
||
-- Path (a): explicit album-likes >30 days old.
|
||
SELECT al.id AS album_id, gla.liked_at AS like_at
|
||
FROM general_likes_albums gla
|
||
JOIN albums al ON al.id = gla.album_id
|
||
WHERE gla.user_id = $1
|
||
AND gla.liked_at < now() - interval '30 days'
|
||
UNION ALL
|
||
-- Path (b): >=2 liked tracks per album, earliest like >30 days old.
|
||
SELECT t.album_id, MIN(gl.liked_at) AS like_at
|
||
FROM general_likes gl
|
||
JOIN tracks t ON t.id = gl.track_id
|
||
WHERE gl.user_id = $1
|
||
GROUP BY t.album_id
|
||
HAVING COUNT(*) >= 2
|
||
AND MIN(gl.liked_at) < now() - interval '30 days'
|
||
),
|
||
eligible AS (
|
||
SELECT ls.album_id,
|
||
MIN(ls.like_at) AS like_at,
|
||
max(pe.started_at) AS last_played
|
||
FROM liked_signal ls
|
||
LEFT JOIN tracks t ON t.album_id = ls.album_id
|
||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||
GROUP BY ls.album_id
|
||
HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz)
|
||
< now() - interval '14 days'
|
||
)
|
||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||
FROM eligible e
|
||
JOIN albums ON albums.id = e.album_id
|
||
JOIN artists ON artists.id = albums.artist_id
|
||
ORDER BY md5(albums.id::text || current_date::text)
|
||
LIMIT $2
|
||
`
|
||
|
||
type ListRediscoverAlbumsForUserParams struct {
|
||
UserID pgtype.UUID
|
||
Limit int32
|
||
}
|
||
|
||
type ListRediscoverAlbumsForUserRow struct {
|
||
Album Album
|
||
ArtistName string
|
||
}
|
||
|
||
// Albums eligible for Rediscover via either signal:
|
||
//
|
||
// (a) explicit album-like (general_likes_albums) liked >30 days ago, OR
|
||
// (b) >=2 liked tracks from the album (general_likes), where the
|
||
// earliest such track-like is >30 days ago.
|
||
//
|
||
// AND not played in the last 14 days. The HAVING epoch sentinel keeps
|
||
// albums with NO play history eligible (treated as last-played in 1970).
|
||
// Ordering is daily-stable random (md5 of album+user+date hashes),
|
||
// so the row rotates at midnight local without re-running per request.
|
||
// Final cap + diversity + cross-section dedup land in the Go layer
|
||
// (internal/recommendation/home.go).
|
||
func (q *Queries) ListRediscoverAlbumsForUser(ctx context.Context, arg ListRediscoverAlbumsForUserParams) ([]ListRediscoverAlbumsForUserRow, error) {
|
||
rows, err := q.db.Query(ctx, listRediscoverAlbumsForUser, arg.UserID, arg.Limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var items []ListRediscoverAlbumsForUserRow
|
||
for rows.Next() {
|
||
var i ListRediscoverAlbumsForUserRow
|
||
if err := rows.Scan(
|
||
&i.Album.ID,
|
||
&i.Album.Title,
|
||
&i.Album.SortTitle,
|
||
&i.Album.ArtistID,
|
||
&i.Album.ReleaseDate,
|
||
&i.Album.Mbid,
|
||
&i.Album.CoverArtPath,
|
||
&i.Album.CreatedAt,
|
||
&i.Album.UpdatedAt,
|
||
&i.Album.CoverArtSource,
|
||
&i.Album.CoverArtSourcesVersion,
|
||
&i.ArtistName,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, i)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
const listRediscoverArtistsFallbackForUser = `-- name: ListRediscoverArtistsFallbackForUser :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 general_likes_artists gla
|
||
JOIN artists ON artists.id = gla.artist_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
|
||
WHERE gla.user_id = $1
|
||
ORDER BY md5(artists.id::text || current_date::text)
|
||
LIMIT $2
|
||
`
|
||
|
||
type ListRediscoverArtistsFallbackForUserParams struct {
|
||
UserID pgtype.UUID
|
||
Limit int32
|
||
}
|
||
|
||
type ListRediscoverArtistsFallbackForUserRow struct {
|
||
Artist Artist
|
||
CoverAlbumID pgtype.UUID
|
||
AlbumCount int64
|
||
}
|
||
|
||
// Daily-stable random sample of liked artists, paired with the Go
|
||
// top-up logic in HomeData when the primary eligibility query returns
|
||
// fewer than the inner limit. Daily hash ordering matches the primary
|
||
// (omitting user_id from the hash for the same sqlc-typing reason).
|
||
func (q *Queries) ListRediscoverArtistsFallbackForUser(ctx context.Context, arg ListRediscoverArtistsFallbackForUserParams) ([]ListRediscoverArtistsFallbackForUserRow, error) {
|
||
rows, err := q.db.Query(ctx, listRediscoverArtistsFallbackForUser, arg.UserID, arg.Limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var items []ListRediscoverArtistsFallbackForUserRow
|
||
for rows.Next() {
|
||
var i ListRediscoverArtistsFallbackForUserRow
|
||
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 listRediscoverArtistsForUser = `-- name: ListRediscoverArtistsForUser :many
|
||
WITH liked_signal AS (
|
||
SELECT a.id AS artist_id, gla.liked_at AS like_at
|
||
FROM general_likes_artists gla
|
||
JOIN artists a ON a.id = gla.artist_id
|
||
WHERE gla.user_id = $1
|
||
AND gla.liked_at < now() - interval '30 days'
|
||
UNION ALL
|
||
SELECT t.artist_id, MIN(gl.liked_at) AS like_at
|
||
FROM general_likes gl
|
||
JOIN tracks t ON t.id = gl.track_id
|
||
WHERE gl.user_id = $1
|
||
GROUP BY t.artist_id
|
||
HAVING COUNT(*) >= 3
|
||
AND MIN(gl.liked_at) < now() - interval '30 days'
|
||
),
|
||
eligible AS (
|
||
SELECT ls.artist_id,
|
||
MIN(ls.like_at) AS like_at,
|
||
max(pe.started_at) AS last_played
|
||
FROM liked_signal ls
|
||
LEFT JOIN tracks t ON t.artist_id = ls.artist_id
|
||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||
GROUP BY ls.artist_id
|
||
HAVING COALESCE(max(pe.started_at), '1970-01-01'::timestamptz)
|
||
< now() - interval '14 days'
|
||
)
|
||
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 eligible e
|
||
JOIN artists ON artists.id = e.artist_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 md5(artists.id::text || current_date::text)
|
||
LIMIT $2
|
||
`
|
||
|
||
type ListRediscoverArtistsForUserParams struct {
|
||
UserID pgtype.UUID
|
||
Limit int32
|
||
}
|
||
|
||
type ListRediscoverArtistsForUserRow struct {
|
||
Artist Artist
|
||
CoverAlbumID pgtype.UUID
|
||
AlbumCount int64
|
||
}
|
||
|
||
// Artists eligible for Rediscover via either signal:
|
||
//
|
||
// (a) explicit artist-like (general_likes_artists) liked >30 days ago, OR
|
||
// (b) >=3 liked tracks from the artist (general_likes), where the
|
||
// earliest such track-like is >30 days ago.
|
||
//
|
||
// AND none of their tracks played in the last 14 days. Higher track
|
||
// threshold than the album path (3 vs 2) because artists span many
|
||
// releases, so the bar is higher to avoid surfacing one-hit-wonder
|
||
// affinities. Ordering is daily-stable random (md5 of artist+user+date
|
||
// hashes). cover_album_id derived from a representative album (most
|
||
// recently created album with cover_art_path set).
|
||
func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRediscoverArtistsForUserParams) ([]ListRediscoverArtistsForUserRow, error) {
|
||
rows, err := q.db.Query(ctx, listRediscoverArtistsForUser, arg.UserID, arg.Limit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var items []ListRediscoverArtistsForUserRow
|
||
for rows.Next() {
|
||
var i ListRediscoverArtistsForUserRow
|
||
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 loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
||
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, t.tag_source, t.tag_sources_version,
|
||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||
pe.last_played_at::timestamptz AS last_played_at,
|
||
pe.play_count,
|
||
pe.skip_count,
|
||
al.release_date AS release_date
|
||
FROM tracks t
|
||
JOIN albums al ON al.id = t.album_id
|
||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||
LEFT JOIN LATERAL (
|
||
SELECT
|
||
max(started_at) AS last_played_at,
|
||
count(*) AS play_count,
|
||
count(*) FILTER (WHERE was_skipped) AS skip_count
|
||
FROM play_events
|
||
WHERE user_id = $1 AND track_id = t.id
|
||
) pe ON true
|
||
WHERE t.id <> $2
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM play_events
|
||
WHERE user_id = $1 AND track_id = t.id
|
||
AND started_at > now() - $3 * interval '1 hour'
|
||
)
|
||
AND NOT EXISTS (
|
||
SELECT 1 FROM lidarr_quarantine q
|
||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||
)
|
||
`
|
||
|
||
type LoadRadioCandidatesParams struct {
|
||
UserID pgtype.UUID
|
||
ID pgtype.UUID
|
||
Column3 interface{}
|
||
}
|
||
|
||
type LoadRadioCandidatesRow struct {
|
||
Track Track
|
||
IsLiked bool
|
||
LastPlayedAt pgtype.Timestamptz
|
||
PlayCount int64
|
||
SkipCount int64
|
||
ReleaseDate pgtype.Date
|
||
}
|
||
|
||
// Returns all tracks except the seed and any played by the user within
|
||
// the last $3 hours, joined with the stats needed for scoring:
|
||
//
|
||
// is_liked — boolean from general_likes for this user
|
||
// last_played_at — max(play_events.started_at) for this user/track
|
||
// play_count — count of play_events for this user/track
|
||
// skip_count — play_events where was_skipped=true
|
||
func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidatesParams) ([]LoadRadioCandidatesRow, error) {
|
||
rows, err := q.db.Query(ctx, loadRadioCandidates, arg.UserID, arg.ID, arg.Column3)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var items []LoadRadioCandidatesRow
|
||
for rows.Next() {
|
||
var i LoadRadioCandidatesRow
|
||
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.Track.TagSource,
|
||
&i.Track.TagSourcesVersion,
|
||
&i.IsLiked,
|
||
&i.LastPlayedAt,
|
||
&i.PlayCount,
|
||
&i.SkipCount,
|
||
&i.ReleaseDate,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, i)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
const loadRadioCandidatesV2 = `-- name: LoadRadioCandidatesV2 :many
|
||
|
||
WITH
|
||
seed_artist AS (
|
||
SELECT artist_id
|
||
FROM tracks
|
||
WHERE tracks.id = $2
|
||
),
|
||
seed_tags AS (
|
||
SELECT trim(g) AS tag
|
||
FROM tracks t
|
||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g(tag) ON true
|
||
WHERE t.id = $2 AND trim(g) <> ''
|
||
),
|
||
excluded_ids AS (
|
||
SELECT unnest($4::uuid[]) AS id
|
||
UNION ALL
|
||
SELECT pe.track_id AS id
|
||
FROM play_events pe
|
||
WHERE pe.user_id = $1 AND pe.started_at > now() - $3 * interval '1 hour'
|
||
UNION ALL
|
||
SELECT q.track_id AS id
|
||
FROM lidarr_quarantine q
|
||
WHERE q.user_id = $1
|
||
),
|
||
lb_similar AS (
|
||
SELECT ts.track_b_id AS track_id, ts.score AS sim_score
|
||
FROM track_similarity ts
|
||
WHERE ts.track_a_id = $2
|
||
AND ts.source = 'listenbrainz'
|
||
AND ts.track_b_id NOT IN (SELECT id FROM excluded_ids)
|
||
ORDER BY ts.score DESC
|
||
LIMIT $5
|
||
),
|
||
similar_artists AS (
|
||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
||
FROM artist_similarity asim
|
||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||
WHERE asim.source = 'listenbrainz'
|
||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||
ORDER BY asim.score DESC, random()
|
||
LIMIT $6
|
||
),
|
||
tag_overlap AS (
|
||
SELECT t.id AS track_id,
|
||
(count(DISTINCT trim(g))::float8
|
||
/ GREATEST((SELECT count(*) FROM seed_tags), 1)) AS sim_score
|
||
FROM tracks t
|
||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||
WHERE trim(g_split.g) IN (SELECT tag FROM seed_tags)
|
||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||
AND t.id <> $2
|
||
GROUP BY t.id
|
||
HAVING count(DISTINCT trim(g_split.g)) > 0
|
||
ORDER BY sim_score DESC
|
||
LIMIT $7
|
||
),
|
||
likes_overlap AS (
|
||
SELECT gl.track_id, 0.6::float8 AS sim_score
|
||
FROM general_likes gl
|
||
WHERE gl.user_id = $1
|
||
AND gl.track_id NOT IN (SELECT id FROM excluded_ids)
|
||
AND EXISTS (
|
||
SELECT 1 FROM tracks t
|
||
LEFT JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_overlap(g) ON true
|
||
WHERE t.id = gl.track_id
|
||
AND trim(g_overlap.g) IN (SELECT tag FROM seed_tags)
|
||
)
|
||
ORDER BY random()
|
||
LIMIT $8
|
||
),
|
||
taste_overlap AS (
|
||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||
FROM taste_profile_artists tpa
|
||
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||
WHERE tpa.user_id = $1
|
||
AND tpa.weight > 0
|
||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||
AND t.id <> $2
|
||
ORDER BY tpa.weight DESC, t.id
|
||
LIMIT $10
|
||
),
|
||
coplay_artists AS (
|
||
-- Household co-play (#1533): tracks by artists co-played across the
|
||
-- instance with the seed's artist (source='user_cooccurrence', built by
|
||
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
||
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
||
-- similar_artists since it's artist-level.
|
||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
||
FROM artist_similarity asim
|
||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||
WHERE asim.source = 'user_cooccurrence'
|
||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||
AND t.id <> $2
|
||
ORDER BY asim.score DESC, random()
|
||
LIMIT $11
|
||
),
|
||
random_fill AS (
|
||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||
FROM tracks t
|
||
WHERE t.id NOT IN (SELECT id FROM excluded_ids)
|
||
AND t.id <> $2
|
||
AND t.id NOT IN (
|
||
SELECT track_id FROM lb_similar
|
||
UNION SELECT track_id FROM similar_artists
|
||
UNION SELECT track_id FROM tag_overlap
|
||
UNION SELECT track_id FROM likes_overlap
|
||
UNION SELECT track_id FROM taste_overlap
|
||
UNION SELECT track_id FROM coplay_artists
|
||
)
|
||
ORDER BY random()
|
||
LIMIT $9
|
||
)
|
||
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, t.tag_source, t.tag_sources_version,
|
||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||
pe.last_played_at::timestamptz AS last_played_at,
|
||
pe.play_count,
|
||
pe.skip_count,
|
||
al.release_date AS release_date,
|
||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||
FROM (
|
||
SELECT track_id, sim_score FROM lb_similar
|
||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||
) u
|
||
JOIN tracks t ON t.id = u.track_id
|
||
JOIN albums al ON al.id = t.album_id
|
||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||
LEFT JOIN LATERAL (
|
||
SELECT max(started_at) AS last_played_at,
|
||
count(*) AS play_count,
|
||
count(*) FILTER (WHERE was_skipped) AS skip_count
|
||
FROM play_events
|
||
WHERE user_id = $1 AND track_id = t.id
|
||
) pe ON true
|
||
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,
|
||
al.release_date
|
||
`
|
||
|
||
type LoadRadioCandidatesV2Params struct {
|
||
UserID pgtype.UUID
|
||
ID pgtype.UUID
|
||
Column3 interface{}
|
||
Column4 []pgtype.UUID
|
||
Limit int32
|
||
Limit_2 int32
|
||
Limit_3 int32
|
||
Limit_4 int32
|
||
Limit_5 int32
|
||
Limit_6 int32
|
||
Limit_7 int32
|
||
}
|
||
|
||
type LoadRadioCandidatesV2Row struct {
|
||
Track Track
|
||
IsLiked bool
|
||
LastPlayedAt pgtype.Timestamptz
|
||
PlayCount int64
|
||
SkipCount int64
|
||
ReleaseDate pgtype.Date
|
||
SimilarityScore interface{}
|
||
}
|
||
|
||
// M4c: similarity-driven candidate pool. 6-way UNION:
|
||
//
|
||
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||
// enter the pool even when the similarity/random arms miss them; scored
|
||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
|
||
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
|
||
// instance with the seed's artist; source='user_cooccurrence').
|
||
//
|
||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||
rows, err := q.db.Query(ctx, loadRadioCandidatesV2,
|
||
arg.UserID,
|
||
arg.ID,
|
||
arg.Column3,
|
||
arg.Column4,
|
||
arg.Limit,
|
||
arg.Limit_2,
|
||
arg.Limit_3,
|
||
arg.Limit_4,
|
||
arg.Limit_5,
|
||
arg.Limit_6,
|
||
arg.Limit_7,
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
var items []LoadRadioCandidatesV2Row
|
||
for rows.Next() {
|
||
var i LoadRadioCandidatesV2Row
|
||
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.Track.TagSource,
|
||
&i.Track.TagSourcesVersion,
|
||
&i.IsLiked,
|
||
&i.LastPlayedAt,
|
||
&i.PlayCount,
|
||
&i.SkipCount,
|
||
&i.ReleaseDate,
|
||
&i.SimilarityScore,
|
||
); err != nil {
|
||
return nil, err
|
||
}
|
||
items = append(items, i)
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
return nil, err
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
||
WITH artist_plays AS (
|
||
-- Completed plays only. The previous seed query counted every play_event,
|
||
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||
-- its neighbours at the user (issue #2367 mechanism 3).
|
||
SELECT t.artist_id, count(*)::bigint AS play_count
|
||
FROM play_events pe
|
||
JOIN tracks t ON t.id = pe.track_id
|
||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||
GROUP BY t.artist_id
|
||
),
|
||
profile_seeds AS (
|
||
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||
FROM taste_profile_artists tpa
|
||
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||
),
|
||
fallback_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 raw_signal
|
||
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 AND pe.was_skipped = false
|
||
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||
GROUP BY a.id, gla.artist_id
|
||
),
|
||
seeds AS (
|
||
SELECT s.artist_id,
|
||
ln(1.0 + s.raw_signal) AS signal,
|
||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||
FROM (
|
||
SELECT artist_id, raw_signal FROM profile_seeds
|
||
UNION ALL
|
||
SELECT artist_id, raw_signal FROM fallback_seeds
|
||
) s
|
||
LEFT JOIN general_likes_artists gla
|
||
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||
WHERE s.raw_signal > 0
|
||
),
|
||
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
|
||
}
|
||
|
||
// Per-user artist suggestions ranked by taste signal x similarity, projected
|
||
// through artist_similarity_unmatched (out-of-library candidates only).
|
||
//
|
||
// Seeds are TIERED (rule #131) so the surface never empties:
|
||
//
|
||
// tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||
// SIGNED by internal/taste, so an artist the user has drifted away
|
||
// from stops contributing instead of accumulating forever.
|
||
// tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||
// (new account, or before the first daily recompute).
|
||
//
|
||
// The signal is log-damped: contribution is signal x similarity, and the old
|
||
// undamped sum let one heavily-played artist's neighbours take every slot --
|
||
// entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||
//
|
||
// Candidates already in the library, or already requested and not terminal,
|
||
// are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $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
|
||
}
|