9e02878b61
Two approved composition changes (#1269), mechanism only — the taste/fresh share stays data-decided (#1252) and pick_kind attribution is unchanged. Multi-seed blending: each day's build now seeds from up to 3 of the user's top-5 tracks (pickDailySeeds, the generalized daily shuffle) instead of one rotating anchor, so the mix spans neighborhoods within a day and stops feeling bipolar as the rotation swings between dissimilar seeds. Per-seed pools merge first-seen-deduped; the head is filled best-first under 50/30/20 per-seed quotas (60/40 for two seeds) so one neighborhood can't monopolize it, with thin-seed quota spilling best-first. Score-weighted fresh tail: the tail sample (rank 2*headN onward) was uniform — the 380th-best candidate as likely as the 101st. It now uses deterministic Efraimidis-Spirakis keys with weight halving every 50 ranks, so freshness keeps its "you'll probably enjoy this" half while still rotating daily. The retired single-seed picker's one other caller, You-might-like, moves to pickDailySeeds(n=1) — a single neighborhood per day is right for a short shelf, and the behavior note is inline. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
563 lines
17 KiB
Go
563 lines
17 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: system_playlists.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const clearStaleSystemPlaylistInFlight = `-- name: ClearStaleSystemPlaylistInFlight :exec
|
|
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true
|
|
`
|
|
|
|
// Cron startup recovery: clear any in_flight=true rows from a previously
|
|
// crashed process. Safe because at startup, by definition, nothing is
|
|
// still building.
|
|
func (q *Queries) ClearStaleSystemPlaylistInFlight(ctx context.Context) error {
|
|
_, err := q.db.Exec(ctx, clearStaleSystemPlaylistInFlight)
|
|
return err
|
|
}
|
|
|
|
const createSystemPlaylist = `-- name: CreateSystemPlaylist :one
|
|
INSERT INTO playlists (
|
|
user_id, name, description, is_public,
|
|
kind, system_variant, seed_artist_id, cover_path
|
|
) VALUES ($1, $2, '', false, 'system', $3, $4, $5)
|
|
RETURNING id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at, kind, system_variant, seed_artist_id
|
|
`
|
|
|
|
type CreateSystemPlaylistParams struct {
|
|
UserID pgtype.UUID
|
|
Name string
|
|
SystemVariant *string
|
|
SeedArtistID pgtype.UUID
|
|
CoverPath *string
|
|
}
|
|
|
|
// Inserts a system-generated playlist. Used by BuildSystemPlaylists.
|
|
// For 'for_you' variant, pass seed_artist_id as NULL (zero-value pgtype.UUID
|
|
// with Valid=false).
|
|
func (q *Queries) CreateSystemPlaylist(ctx context.Context, arg CreateSystemPlaylistParams) (Playlist, error) {
|
|
row := q.db.QueryRow(ctx, createSystemPlaylist,
|
|
arg.UserID,
|
|
arg.Name,
|
|
arg.SystemVariant,
|
|
arg.SeedArtistID,
|
|
arg.CoverPath,
|
|
)
|
|
var i Playlist
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.IsPublic,
|
|
&i.CoverPath,
|
|
&i.TrackCount,
|
|
&i.DurationSec,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.Kind,
|
|
&i.SystemVariant,
|
|
&i.SeedArtistID,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const deleteSystemPlaylistsForUser = `-- name: DeleteSystemPlaylistsForUser :exec
|
|
DELETE FROM playlists WHERE user_id = $1 AND kind = 'system'
|
|
`
|
|
|
|
// Atomic-replace step. CASCADE on playlist_tracks.playlist_id handles
|
|
// the join rows.
|
|
func (q *Queries) DeleteSystemPlaylistsForUser(ctx context.Context, userID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, deleteSystemPlaylistsForUser, userID)
|
|
return err
|
|
}
|
|
|
|
const failSystemPlaylistRun = `-- name: FailSystemPlaylistRun :exec
|
|
UPDATE system_playlist_runs
|
|
SET in_flight = false,
|
|
last_error = $2
|
|
WHERE user_id = $1
|
|
`
|
|
|
|
type FailSystemPlaylistRunParams struct {
|
|
UserID pgtype.UUID
|
|
LastError *string
|
|
}
|
|
|
|
// Mark a failed build complete (in_flight=false, error captured).
|
|
func (q *Queries) FailSystemPlaylistRun(ctx context.Context, arg FailSystemPlaylistRunParams) error {
|
|
_, err := q.db.Exec(ctx, failSystemPlaylistRun, arg.UserID, arg.LastError)
|
|
return err
|
|
}
|
|
|
|
const finishSystemPlaylistRun = `-- name: FinishSystemPlaylistRun :exec
|
|
UPDATE system_playlist_runs
|
|
SET last_run_at = $2,
|
|
last_run_date = $3,
|
|
in_flight = false,
|
|
last_error = NULL
|
|
WHERE user_id = $1
|
|
`
|
|
|
|
type FinishSystemPlaylistRunParams struct {
|
|
UserID pgtype.UUID
|
|
LastRunAt pgtype.Timestamptz
|
|
LastRunDate pgtype.Date
|
|
}
|
|
|
|
// Mark a successful build complete.
|
|
func (q *Queries) FinishSystemPlaylistRun(ctx context.Context, arg FinishSystemPlaylistRunParams) error {
|
|
_, err := q.db.Exec(ctx, finishSystemPlaylistRun, arg.UserID, arg.LastRunAt, arg.LastRunDate)
|
|
return err
|
|
}
|
|
|
|
const getSystemPlaylistByVariantForUser = `-- name: GetSystemPlaylistByVariantForUser :one
|
|
SELECT p.id, p.user_id, p.name, p.kind, p.system_variant,
|
|
p.seed_artist_id, p.cover_path, p.track_count
|
|
FROM playlists p
|
|
WHERE p.user_id = $1
|
|
AND p.kind = 'system'
|
|
AND p.system_variant = $2
|
|
ORDER BY p.created_at DESC
|
|
LIMIT 1
|
|
`
|
|
|
|
type GetSystemPlaylistByVariantForUserParams struct {
|
|
UserID pgtype.UUID
|
|
SystemVariant *string
|
|
}
|
|
|
|
type GetSystemPlaylistByVariantForUserRow struct {
|
|
ID pgtype.UUID
|
|
UserID pgtype.UUID
|
|
Name string
|
|
Kind string
|
|
SystemVariant *string
|
|
SeedArtistID pgtype.UUID
|
|
CoverPath *string
|
|
TrackCount int32
|
|
}
|
|
|
|
// Returns the user's system playlist for the given variant
|
|
// ('for_you' / 'songs_like_artist' / 'discover'), or pgx.ErrNoRows
|
|
// when the user hasn't built that variant yet. For
|
|
// 'songs_like_artist' (which can have multiple rows per user, one
|
|
// per seed), this returns whichever LIMIT 1 picks — callers that
|
|
// need all instances should use a different query.
|
|
func (q *Queries) GetSystemPlaylistByVariantForUser(ctx context.Context, arg GetSystemPlaylistByVariantForUserParams) (GetSystemPlaylistByVariantForUserRow, error) {
|
|
row := q.db.QueryRow(ctx, getSystemPlaylistByVariantForUser, arg.UserID, arg.SystemVariant)
|
|
var i GetSystemPlaylistByVariantForUserRow
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Kind,
|
|
&i.SystemVariant,
|
|
&i.SeedArtistID,
|
|
&i.CoverPath,
|
|
&i.TrackCount,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getSystemPlaylistRun = `-- name: GetSystemPlaylistRun :one
|
|
SELECT user_id, last_run_at, last_run_date, in_flight, last_error
|
|
FROM system_playlist_runs
|
|
WHERE user_id = $1
|
|
`
|
|
|
|
func (q *Queries) GetSystemPlaylistRun(ctx context.Context, userID pgtype.UUID) (SystemPlaylistRun, error) {
|
|
row := q.db.QueryRow(ctx, getSystemPlaylistRun, userID)
|
|
var i SystemPlaylistRun
|
|
err := row.Scan(
|
|
&i.UserID,
|
|
&i.LastRunAt,
|
|
&i.LastRunDate,
|
|
&i.InFlight,
|
|
&i.LastError,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const listActiveUsersForSystemPlaylists = `-- name: ListActiveUsersForSystemPlaylists :many
|
|
|
|
SELECT u.id FROM users u
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM play_events pe
|
|
WHERE pe.user_id = u.id
|
|
AND pe.started_at > now() - INTERVAL '7 days'
|
|
)
|
|
`
|
|
|
|
// M7 #352 slice 2: system-generated playlist queries.
|
|
// Active = had a play in the last 7 days. The cron iterates this list.
|
|
func (q *Queries) ListActiveUsersForSystemPlaylists(ctx context.Context) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listActiveUsersForSystemPlaylists)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []pgtype.UUID
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listPlaylistsByUserAndKind = `-- name: ListPlaylistsByUserAndKind :many
|
|
SELECT id, user_id, name, description, is_public, kind, system_variant,
|
|
seed_artist_id, cover_path, track_count, duration_sec,
|
|
created_at, updated_at
|
|
FROM playlists
|
|
WHERE user_id = $1
|
|
AND ($2::text = 'all' OR kind = $2)
|
|
ORDER BY updated_at DESC
|
|
`
|
|
|
|
type ListPlaylistsByUserAndKindParams struct {
|
|
UserID pgtype.UUID
|
|
Column2 string
|
|
}
|
|
|
|
type ListPlaylistsByUserAndKindRow struct {
|
|
ID pgtype.UUID
|
|
UserID pgtype.UUID
|
|
Name string
|
|
Description string
|
|
IsPublic bool
|
|
Kind string
|
|
SystemVariant *string
|
|
SeedArtistID pgtype.UUID
|
|
CoverPath *string
|
|
TrackCount int32
|
|
DurationSec int32
|
|
CreatedAt pgtype.Timestamptz
|
|
UpdatedAt pgtype.Timestamptz
|
|
}
|
|
|
|
// Used in integration tests; production filter applied in Go
|
|
// (api.handleListPlaylists fetches via Service.List and filters by Kind in
|
|
// memory so it can also surface other users' public playlists alongside
|
|
// the caller's own kind-filtered ones).
|
|
// Sorted by updated_at DESC.
|
|
func (q *Queries) ListPlaylistsByUserAndKind(ctx context.Context, arg ListPlaylistsByUserAndKindParams) ([]ListPlaylistsByUserAndKindRow, error) {
|
|
rows, err := q.db.Query(ctx, listPlaylistsByUserAndKind, arg.UserID, arg.Column2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListPlaylistsByUserAndKindRow
|
|
for rows.Next() {
|
|
var i ListPlaylistsByUserAndKindRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Name,
|
|
&i.Description,
|
|
&i.IsPublic,
|
|
&i.Kind,
|
|
&i.SystemVariant,
|
|
&i.SeedArtistID,
|
|
&i.CoverPath,
|
|
&i.TrackCount,
|
|
&i.DurationSec,
|
|
&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 pickSeedArtists = `-- name: PickSeedArtists :many
|
|
WITH liked AS (
|
|
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
|
),
|
|
recent7 AS (
|
|
SELECT t.artist_id,
|
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
|
0 AS tier
|
|
FROM play_events pe
|
|
JOIN tracks t ON t.id = pe.track_id
|
|
WHERE pe.user_id = $1
|
|
AND pe.started_at > now() - INTERVAL '7 days'
|
|
AND t.artist_id IS NOT NULL
|
|
GROUP BY t.artist_id
|
|
),
|
|
recent30 AS (
|
|
SELECT t.artist_id,
|
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
|
1 AS tier
|
|
FROM play_events pe
|
|
JOIN tracks t ON t.id = pe.track_id
|
|
WHERE pe.user_id = $1
|
|
AND pe.started_at > now() - INTERVAL '30 days'
|
|
AND t.artist_id IS NOT NULL
|
|
GROUP BY t.artist_id
|
|
),
|
|
alltime AS (
|
|
SELECT t.artist_id,
|
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
|
2 AS tier
|
|
FROM play_events pe
|
|
JOIN tracks t ON t.id = pe.track_id
|
|
WHERE pe.user_id = $1
|
|
AND t.artist_id IS NOT NULL
|
|
GROUP BY t.artist_id
|
|
),
|
|
likedonly AS (
|
|
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
|
|
FROM liked l
|
|
),
|
|
chosen AS (
|
|
SELECT artist_id, play_count, tier FROM recent7
|
|
UNION ALL
|
|
SELECT artist_id, play_count, tier FROM recent30
|
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
|
UNION ALL
|
|
SELECT artist_id, play_count, tier FROM alltime
|
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
|
AND NOT EXISTS (SELECT 1 FROM recent30)
|
|
UNION ALL
|
|
SELECT artist_id, play_count, tier FROM likedonly
|
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
|
AND NOT EXISTS (SELECT 1 FROM recent30)
|
|
AND NOT EXISTS (SELECT 1 FROM alltime)
|
|
)
|
|
SELECT c.artist_id,
|
|
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
|
|
c.tier::int AS tier
|
|
FROM chosen c
|
|
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
|
ORDER BY score DESC, c.artist_id
|
|
LIMIT 5
|
|
`
|
|
|
|
type PickSeedArtistsRow struct {
|
|
ArtistID pgtype.UUID
|
|
Score int64
|
|
Tier int32
|
|
}
|
|
|
|
// Top-5 most-engaged distinct artist candidates, tiered so the
|
|
// "Songs like X" mixes never silently vanish (#1255): the old hard
|
|
// 7-day window emptied the seed pool after a quiet week, and the
|
|
// daily atomic-replace build then deleted every existing mix until
|
|
// the user played something again. Same fallback shape as
|
|
// PickTopPlayedTracksForUser (For You's seeds):
|
|
//
|
|
// tier 0 engagement in the last 7 days
|
|
// tier 1 (only if tier 0 empty) last 30 days
|
|
// tier 2 (only if tiers 0+1 empty) all-time
|
|
// tier 3 (only if 0-2 empty) liked artists with no play history
|
|
//
|
|
// All returned rows share one tier; produceSeedMixes maps it onto the
|
|
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
|
// can compare mixes seeded from fresh vs stale engagement.
|
|
// Score = unskipped-play count + 5 if user has liked the artist. The
|
|
// Go-side picker (pickSeedArtistsForDay) shuffles the 5
|
|
// daily-deterministically and takes 3 so the mix set rotates.
|
|
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
|
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []PickSeedArtistsRow
|
|
for rows.Next() {
|
|
var i PickSeedArtistsRow
|
|
if err := rows.Scan(&i.ArtistID, &i.Score, &i.Tier); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const pickTopAlbumCoverForArtistByUser = `-- name: PickTopAlbumCoverForArtistByUser :one
|
|
SELECT a.cover_art_path
|
|
FROM albums a
|
|
LEFT JOIN tracks t ON t.album_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 a.artist_id = $2
|
|
AND a.cover_art_path IS NOT NULL
|
|
GROUP BY a.id, a.release_date, a.cover_art_path
|
|
ORDER BY COUNT(pe.id) DESC, a.release_date DESC NULLS LAST, a.id
|
|
LIMIT 1
|
|
`
|
|
|
|
type PickTopAlbumCoverForArtistByUserParams struct {
|
|
UserID pgtype.UUID
|
|
ArtistID pgtype.UUID
|
|
}
|
|
|
|
// "Songs like X" cover. Most-played album by user; tie-break by most-recent
|
|
// release. NULL if the artist has no albums or all covers are NULL.
|
|
// Note: albums uses cover_art_path (not cover_path).
|
|
func (q *Queries) PickTopAlbumCoverForArtistByUser(ctx context.Context, arg PickTopAlbumCoverForArtistByUserParams) (*string, error) {
|
|
row := q.db.QueryRow(ctx, pickTopAlbumCoverForArtistByUser, arg.UserID, arg.ArtistID)
|
|
var cover_art_path *string
|
|
err := row.Scan(&cover_art_path)
|
|
return cover_art_path, err
|
|
}
|
|
|
|
const pickTopPlayedTrackForArtistByUser = `-- name: PickTopPlayedTrackForArtistByUser :one
|
|
SELECT COALESCE(
|
|
(SELECT t.id
|
|
FROM play_events pe
|
|
JOIN tracks t ON t.id = pe.track_id
|
|
WHERE pe.user_id = $1
|
|
AND t.artist_id = $2
|
|
AND pe.started_at > now() - INTERVAL '7 days'
|
|
AND pe.was_skipped = false
|
|
GROUP BY t.id
|
|
ORDER BY COUNT(*) DESC, t.id
|
|
LIMIT 1),
|
|
(SELECT t.id
|
|
FROM tracks t
|
|
JOIN albums a ON a.id = t.album_id
|
|
WHERE t.artist_id = $2
|
|
ORDER BY a.release_date DESC NULLS LAST,
|
|
t.disc_number NULLS LAST,
|
|
t.track_number NULLS LAST,
|
|
t.id
|
|
LIMIT 1)
|
|
)::uuid AS id
|
|
`
|
|
|
|
type PickTopPlayedTrackForArtistByUserParams struct {
|
|
UserID pgtype.UUID
|
|
ArtistID pgtype.UUID
|
|
}
|
|
|
|
// "Songs like X" seed selection. Returns the user's most-played non-skipped
|
|
// track by artist X in the last 7 days. Falls back to the artist's most-
|
|
// recent album's first track if no plays exist.
|
|
// Cast to uuid so sqlc infers pgtype.UUID rather than interface{}.
|
|
func (q *Queries) PickTopPlayedTrackForArtistByUser(ctx context.Context, arg PickTopPlayedTrackForArtistByUserParams) (pgtype.UUID, error) {
|
|
row := q.db.QueryRow(ctx, pickTopPlayedTrackForArtistByUser, arg.UserID, arg.ArtistID)
|
|
var id pgtype.UUID
|
|
err := row.Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
const pickTopPlayedTracksForUser = `-- name: PickTopPlayedTracksForUser :many
|
|
WITH recent AS (
|
|
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
|
FROM play_events pe
|
|
JOIN tracks t ON t.id = pe.track_id
|
|
WHERE pe.user_id = $1
|
|
AND pe.started_at > now() - INTERVAL '30 days'
|
|
AND pe.was_skipped = false
|
|
GROUP BY t.id
|
|
),
|
|
alltime AS (
|
|
SELECT t.id, COUNT(*) AS c, 1 AS tier
|
|
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.id
|
|
),
|
|
liked AS (
|
|
SELECT gl.track_id AS id, 0::bigint AS c, 2 AS tier
|
|
FROM general_likes gl
|
|
WHERE gl.user_id = $1
|
|
),
|
|
chosen AS (
|
|
SELECT id, c, tier FROM recent
|
|
UNION ALL
|
|
SELECT id, c, tier FROM alltime
|
|
WHERE NOT EXISTS (SELECT 1 FROM recent)
|
|
UNION ALL
|
|
SELECT id, c, tier FROM liked
|
|
WHERE NOT EXISTS (SELECT 1 FROM recent)
|
|
AND NOT EXISTS (SELECT 1 FROM alltime)
|
|
)
|
|
SELECT id
|
|
FROM chosen
|
|
ORDER BY tier, c DESC, id
|
|
LIMIT 5
|
|
`
|
|
|
|
// For-You candidate seeds, tiered so For-You never silently vanishes:
|
|
//
|
|
// tier 0 top non-skip plays in the last 30 days
|
|
// tier 1 (only if tier 0 empty) all-time top non-skip plays
|
|
// tier 2 (only if tiers 0+1 empty) liked tracks
|
|
//
|
|
// Returns up to 5 ids; tie-break by track_id for determinism. The
|
|
// Go-side picker (pickDailySeeds) draws the day's seeds from these.
|
|
// Widened from a hard 7-day window, which made For-You disappear
|
|
// after a week of not listening and never recover on a self-hosted
|
|
// library with sparse history.
|
|
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []pgtype.UUID
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const tryClaimSystemPlaylistRun = `-- name: TryClaimSystemPlaylistRun :one
|
|
INSERT INTO system_playlist_runs (user_id, in_flight)
|
|
VALUES ($1, true)
|
|
ON CONFLICT (user_id) DO UPDATE
|
|
SET in_flight = true
|
|
WHERE system_playlist_runs.in_flight = false
|
|
RETURNING user_id, last_run_at, last_run_date, in_flight, last_error
|
|
`
|
|
|
|
// Atomic test-and-set on in_flight. Returns the row if we won the claim,
|
|
// or pgx.ErrNoRows if someone else is already building.
|
|
func (q *Queries) TryClaimSystemPlaylistRun(ctx context.Context, userID pgtype.UUID) (SystemPlaylistRun, error) {
|
|
row := q.db.QueryRow(ctx, tryClaimSystemPlaylistRun, userID)
|
|
var i SystemPlaylistRun
|
|
err := row.Scan(
|
|
&i.UserID,
|
|
&i.LastRunAt,
|
|
&i.LastRunDate,
|
|
&i.InFlight,
|
|
&i.LastError,
|
|
)
|
|
return i, err
|
|
}
|