diff --git a/internal/db/dbq/system_mixes.sql.go b/internal/db/dbq/system_mixes.sql.go new file mode 100644 index 00000000..5557b28b --- /dev/null +++ b/internal/db/dbq/system_mixes.sql.go @@ -0,0 +1,303 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: system_mixes.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const listDeepCutsTracks = `-- name: ListDeepCutsTracks :many + +WITH affinity_artists AS ( + SELECT artist_id FROM general_likes_artists WHERE user_id = $1 + UNION + SELECT t.artist_id + 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 + HAVING COUNT(*) >= 5 +), +play_counts AS ( + SELECT track_id, COUNT(*) AS c + FROM play_events + WHERE user_id = $1 AND was_skipped = false + GROUP BY track_id +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN affinity_artists aa ON aa.artist_id = t.artist_id + LEFT JOIN play_counts pc ON pc.track_id = t.id + WHERE COALESCE(pc.c, 0) <= 2 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY md5(t.id::text || $2::text) + LIMIT 200 +` + +type ListDeepCutsTracksParams struct { + UserID pgtype.UUID + Column2 string +} + +type ListDeepCutsTracksRow struct { + ID pgtype.UUID + AlbumID pgtype.UUID + ArtistID pgtype.UUID +} + +// Candidate queries for the discovery system-playlist mixes +// (#419-423). Each returns (id, album_id, artist_id) rows; the Go +// producer caps + truncates and converts to rankedCandidate. Daily- +// deterministic md5(id||dateStr) ordering (where used) keeps a mix +// stable within a day while rotating across days; shuffle-on-play +// gives per-play variety on top. +// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user +// has liked OR played heavily (>=5 non-skip plays across the artist). +// $1 user_id, $2 date string. +func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) { + rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDeepCutsTracksRow + for rows.Next() { + var i ListDeepCutsTracksRow + if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listFirstListensTracks = `-- name: ListFirstListensTracks :many +WITH heard_albums AS ( + SELECT DISTINCT t.album_id + FROM play_events pe JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 +), +played_artists AS ( + SELECT DISTINCT t.artist_id + FROM play_events pe JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN albums al ON al.id = t.album_id + WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY + (CASE + WHEN EXISTS ( + SELECT 1 FROM general_likes_artists gla + WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id + ) THEN 0 + WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1 + ELSE 2 + END), + al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST + LIMIT 300 +` + +type ListFirstListensTracksRow struct { + ID pgtype.UUID + AlbumID pgtype.UUID + ArtistID pgtype.UUID +} + +// #423 First Listens: albums the user has never played any track of. +// Tiered: liked-artist albums first, then played-artist albums, then +// the rest — album-coherent within each tier. Not diversity-capped +// (whole-album discovery). $1 user_id. +func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) { + rows, err := q.db.Query(ctx, listFirstListensTracks, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListFirstListensTracksRow + for rows.Next() { + var i ListFirstListensTracksRow + if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listNewForYouTracks = `-- name: ListNewForYouTracks :many +WITH affinity_artists AS ( + SELECT artist_id FROM general_likes_artists WHERE user_id = $1 + UNION + SELECT t.artist_id + 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 + HAVING COUNT(*) >= 3 +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN albums al ON al.id = t.album_id + JOIN affinity_artists aa ON aa.artist_id = al.artist_id + WHERE al.created_at >= now() - interval '30 days' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST + LIMIT 200 +` + +type ListNewForYouTracksRow struct { + ID pgtype.UUID + AlbumID pgtype.UUID + ArtistID pgtype.UUID +} + +// #421 New for you: tracks from albums added in the last 30 days +// whose artist the user has liked OR played (>=3 non-skip). Album- +// coherent (newest album first, then disc/track) — the producer does +// NOT diversity-cap these; they're meant as whole-album discovery. +// $1 user_id. +func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) { + rows, err := q.db.Query(ctx, listNewForYouTracks, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListNewForYouTracksRow + for rows.Next() { + var i ListNewForYouTracksRow + if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listOnThisDayTracks = `-- name: ListOnThisDayTracks :many +WITH windowed AS ( + SELECT track_id, COUNT(*) AS c + FROM play_events + WHERE user_id = $1 AND was_skipped = false + AND started_at < now() - interval '60 days' + AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7 + GROUP BY track_id +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN windowed w ON w.track_id = t.id + WHERE NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY w.c DESC, md5(t.id::text || $2::text) + LIMIT 200 +` + +type ListOnThisDayTracksParams struct { + UserID pgtype.UUID + Column2 string +} + +type ListOnThisDayTracksRow struct { + ID pgtype.UUID + AlbumID pgtype.UUID + ArtistID pgtype.UUID +} + +// #422 On This Day: tracks the user played around this calendar date +// (±7 day-of-year) in the past, excluding the very recent (>60 days +// ago) so it's nostalgic, not just "last week". Weighted by how much +// they were played in those windows. +// $1 user_id, $2 date string. +func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) { + rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListOnThisDayTracksRow + for rows.Next() { + var i ListOnThisDayTracksRow + if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listRediscoverTracks = `-- name: ListRediscoverTracks :many +WITH stats AS ( + SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at + FROM play_events + WHERE user_id = $1 AND was_skipped = false + GROUP BY track_id +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN stats s ON s.track_id = t.id + WHERE s.c >= 5 + AND s.last_at <= now() - interval '6 months' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY s.c DESC, t.id + LIMIT 200 +` + +type ListRediscoverTracksRow struct { + ID pgtype.UUID + AlbumID pgtype.UUID + ArtistID pgtype.UUID +} + +// #420 Rediscover: tracks the user played a lot (>=5 non-skip) but +// not in the last 6 months. Ordered by historical affection. +// $1 user_id. +func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) { + rows, err := q.db.Query(ctx, listRediscoverTracks, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListRediscoverTracksRow + for rows.Next() { + var i ListRediscoverTracksRow + if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/queries/system_mixes.sql b/internal/db/queries/system_mixes.sql new file mode 100644 index 00000000..62074620 --- /dev/null +++ b/internal/db/queries/system_mixes.sql @@ -0,0 +1,145 @@ +-- Candidate queries for the discovery system-playlist mixes +-- (#419-423). Each returns (id, album_id, artist_id) rows; the Go +-- producer caps + truncates and converts to rankedCandidate. Daily- +-- deterministic md5(id||dateStr) ordering (where used) keeps a mix +-- stable within a day while rotating across days; shuffle-on-play +-- gives per-play variety on top. + +-- name: ListDeepCutsTracks :many +-- #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user +-- has liked OR played heavily (>=5 non-skip plays across the artist). +-- $1 user_id, $2 date string. +WITH affinity_artists AS ( + SELECT artist_id FROM general_likes_artists WHERE user_id = $1 + UNION + SELECT t.artist_id + 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 + HAVING COUNT(*) >= 5 +), +play_counts AS ( + SELECT track_id, COUNT(*) AS c + FROM play_events + WHERE user_id = $1 AND was_skipped = false + GROUP BY track_id +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN affinity_artists aa ON aa.artist_id = t.artist_id + LEFT JOIN play_counts pc ON pc.track_id = t.id + WHERE COALESCE(pc.c, 0) <= 2 + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY md5(t.id::text || $2::text) + LIMIT 200; + +-- name: ListRediscoverTracks :many +-- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but +-- not in the last 6 months. Ordered by historical affection. +-- $1 user_id. +WITH stats AS ( + SELECT track_id, COUNT(*) AS c, MAX(started_at) AS last_at + FROM play_events + WHERE user_id = $1 AND was_skipped = false + GROUP BY track_id +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN stats s ON s.track_id = t.id + WHERE s.c >= 5 + AND s.last_at <= now() - interval '6 months' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY s.c DESC, t.id + LIMIT 200; + +-- name: ListNewForYouTracks :many +-- #421 New for you: tracks from albums added in the last 30 days +-- whose artist the user has liked OR played (>=3 non-skip). Album- +-- coherent (newest album first, then disc/track) — the producer does +-- NOT diversity-cap these; they're meant as whole-album discovery. +-- $1 user_id. +WITH affinity_artists AS ( + SELECT artist_id FROM general_likes_artists WHERE user_id = $1 + UNION + SELECT t.artist_id + 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 + HAVING COUNT(*) >= 3 +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN albums al ON al.id = t.album_id + JOIN affinity_artists aa ON aa.artist_id = al.artist_id + WHERE al.created_at >= now() - interval '30 days' + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST + LIMIT 200; + +-- name: ListOnThisDayTracks :many +-- #422 On This Day: tracks the user played around this calendar date +-- (±7 day-of-year) in the past, excluding the very recent (>60 days +-- ago) so it's nostalgic, not just "last week". Weighted by how much +-- they were played in those windows. +-- $1 user_id, $2 date string. +WITH windowed AS ( + SELECT track_id, COUNT(*) AS c + FROM play_events + WHERE user_id = $1 AND was_skipped = false + AND started_at < now() - interval '60 days' + AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 7 + GROUP BY track_id +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN windowed w ON w.track_id = t.id + WHERE NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY w.c DESC, md5(t.id::text || $2::text) + LIMIT 200; + +-- name: ListFirstListensTracks :many +-- #423 First Listens: albums the user has never played any track of. +-- Tiered: liked-artist albums first, then played-artist albums, then +-- the rest — album-coherent within each tier. Not diversity-capped +-- (whole-album discovery). $1 user_id. +WITH heard_albums AS ( + SELECT DISTINCT t.album_id + FROM play_events pe JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 +), +played_artists AS ( + SELECT DISTINCT t.artist_id + FROM play_events pe JOIN tracks t ON t.id = pe.track_id + WHERE pe.user_id = $1 +) +SELECT t.id, t.album_id, t.artist_id + FROM tracks t + JOIN albums al ON al.id = t.album_id + WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id) + AND NOT EXISTS ( + SELECT 1 FROM lidarr_quarantine q + WHERE q.user_id = $1 AND q.track_id = t.id + ) + ORDER BY + (CASE + WHEN EXISTS ( + SELECT 1 FROM general_likes_artists gla + WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id + ) THEN 0 + WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1 + ELSE 2 + END), + al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST + LIMIT 300; diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 97b97e8f..c0610dad 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -282,6 +282,11 @@ var systemPlaylistRegistry = []systemPlaylistKind{ {Key: "for_you", Singleton: true, Produce: produceForYou}, {Key: "songs_like_artist", Singleton: false, Produce: produceSeedMixes}, {Key: "discover", Singleton: true, Produce: produceDiscover}, + {Key: "deep_cuts", Singleton: true, Produce: produceDeepCuts}, + {Key: "rediscover", Singleton: true, Produce: produceRediscover}, + {Key: "new_for_you", Singleton: true, Produce: produceNewForYou}, + {Key: "on_this_day", Singleton: true, Produce: produceOnThisDay}, + {Key: "first_listens", Singleton: true, Produce: produceFirstListens}, } // produceForYou: today's seed from the user's top-5 played tracks diff --git a/internal/playlists/system_mixes.go b/internal/playlists/system_mixes.go new file mode 100644 index 00000000..e57233ea --- /dev/null +++ b/internal/playlists/system_mixes.go @@ -0,0 +1,145 @@ +package playlists + +import ( + "context" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// Discovery mixes (#419-423). Each is one candidate query + a thin +// producer registered in systemPlaylistRegistry. They follow the +// Discover model: a SQL query returns ordered (id, album_id, +// artist_id) rows; we optionally diversity-cap, truncate, and emit +// rankedCandidate (Score unused — SQL gave the ranking). All are +// singleton kinds, so the generic by-kind refresh/shuffle endpoints +// and per-tile refresh affordance work with zero client changes. + +// discoveryMixLen caps each mix at the same depth as For-You / +// Discover so shuffle-on-play has a varied pool within a day. +const discoveryMixLen = 100 + +// finishMix caps (per-album<=2 / per-artist<=3) when diversify is +// set, truncates to discoveryMixLen, and converts to the insert +// type. Album-coherent mixes (New for you, First Listens) pass +// diversify=false so whole albums survive. +func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate { + pool := rows + if diversify { + pool = capByAlbumAndArtist(pool) + } + if len(pool) > discoveryMixLen { + pool = pool[:discoveryMixLen] + } + if len(pool) == 0 { + return nil + } + tracks := make([]rankedCandidate, len(pool)) + for i, t := range pool { + tracks[i] = rankedCandidate{TrackID: t.ID} + } + return tracks +} + +// emit wraps the finished track list in a single builtPlaylist (the +// discovery mixes are all singletons). nil tracks → no playlist. +func emit(name, variant string, tracks []rankedCandidate) []builtPlaylist { + if len(tracks) == 0 { + return nil + } + return []builtPlaylist{{Name: name, Variant: variant, Tracks: tracks}} +} + +func produceDeepCuts( + ctx context.Context, q *dbq.Queries, logger *slog.Logger, + userID pgtype.UUID, dateStr string, _ time.Time, +) ([]builtPlaylist, error) { + rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{ + UserID: userID, Column2: dateStr, + }) + if err != nil { + logger.Warn("system playlist: deep-cuts query failed; skipping", + "user_id", uuidStringPL(userID), "err", err) + return nil, nil + } + dt := make([]discoverTrack, len(rows)) + for i, r := range rows { + dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} + } + return emit("Deep Cuts", "deep_cuts", finishMix(dt, true)), nil +} + +func produceRediscover( + ctx context.Context, q *dbq.Queries, logger *slog.Logger, + userID pgtype.UUID, _ string, _ time.Time, +) ([]builtPlaylist, error) { + rows, err := q.ListRediscoverTracks(ctx, userID) + if err != nil { + logger.Warn("system playlist: rediscover query failed; skipping", + "user_id", uuidStringPL(userID), "err", err) + return nil, nil + } + dt := make([]discoverTrack, len(rows)) + for i, r := range rows { + dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} + } + return emit("Rediscover", "rediscover", finishMix(dt, true)), nil +} + +func produceNewForYou( + ctx context.Context, q *dbq.Queries, logger *slog.Logger, + userID pgtype.UUID, _ string, _ time.Time, +) ([]builtPlaylist, error) { + rows, err := q.ListNewForYouTracks(ctx, userID) + if err != nil { + logger.Warn("system playlist: new-for-you query failed; skipping", + "user_id", uuidStringPL(userID), "err", err) + return nil, nil + } + dt := make([]discoverTrack, len(rows)) + for i, r := range rows { + dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} + } + // Album-coherent: no diversity cap so whole new albums survive. + return emit("New for you", "new_for_you", finishMix(dt, false)), nil +} + +func produceOnThisDay( + ctx context.Context, q *dbq.Queries, logger *slog.Logger, + userID pgtype.UUID, dateStr string, _ time.Time, +) ([]builtPlaylist, error) { + rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{ + UserID: userID, Column2: dateStr, + }) + if err != nil { + logger.Warn("system playlist: on-this-day query failed; skipping", + "user_id", uuidStringPL(userID), "err", err) + return nil, nil + } + dt := make([]discoverTrack, len(rows)) + for i, r := range rows { + dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} + } + return emit("On this day", "on_this_day", finishMix(dt, true)), nil +} + +func produceFirstListens( + ctx context.Context, q *dbq.Queries, logger *slog.Logger, + userID pgtype.UUID, _ string, _ time.Time, +) ([]builtPlaylist, error) { + rows, err := q.ListFirstListensTracks(ctx, userID) + if err != nil { + logger.Warn("system playlist: first-listens query failed; skipping", + "user_id", uuidStringPL(userID), "err", err) + return nil, nil + } + dt := make([]discoverTrack, len(rows)) + for i, r := range rows { + dt[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID} + } + // Album-coherent (tiered by liked/played artist in SQL): no cap. + return emit("First listens", "first_listens", finishMix(dt, false)), nil +}