diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index e406323f..15ebe3a3 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -336,16 +336,19 @@ type PlaySession struct { } type Playlist struct { - ID pgtype.UUID - UserID pgtype.UUID - Name string - Description string - IsPublic bool - CoverPath *string - TrackCount int32 - DurationSec int32 - CreatedAt pgtype.Timestamptz - UpdatedAt pgtype.Timestamptz + ID pgtype.UUID + UserID pgtype.UUID + Name string + Description string + IsPublic bool + CoverPath *string + TrackCount int32 + DurationSec int32 + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz + Kind string + SystemVariant *string + SeedArtistID pgtype.UUID } type PlaylistTrack struct { @@ -388,6 +391,14 @@ type SkipEvent struct { PositionMs int32 } +type SystemPlaylistRun struct { + UserID pgtype.UUID + LastRunAt pgtype.Timestamptz + LastRunDate pgtype.Date + InFlight bool + LastError *string +} + type Track struct { ID pgtype.UUID Title string diff --git a/internal/db/dbq/playlists.sql.go b/internal/db/dbq/playlists.sql.go index 750a47cc..80d97dcc 100644 --- a/internal/db/dbq/playlists.sql.go +++ b/internal/db/dbq/playlists.sql.go @@ -55,7 +55,7 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra const createPlaylist = `-- name: CreatePlaylist :one INSERT INTO playlists (user_id, name, description, is_public) VALUES ($1, $2, $3, $4) -RETURNING id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at +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 CreatePlaylistParams struct { @@ -84,6 +84,9 @@ func (q *Queries) CreatePlaylist(ctx context.Context, arg CreatePlaylistParams) &i.DurationSec, &i.CreatedAt, &i.UpdatedAt, + &i.Kind, + &i.SystemVariant, + &i.SeedArtistID, ) return i, err } @@ -125,7 +128,7 @@ func (q *Queries) DeletePlaylistTrack(ctx context.Context, arg DeletePlaylistTra } const getPlaylist = `-- name: GetPlaylist :one -SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, u.username AS owner_username +SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, p.kind, p.system_variant, p.seed_artist_id, u.username AS owner_username FROM playlists p JOIN users u ON u.id = p.user_id WHERE p.id = $1 @@ -142,6 +145,9 @@ type GetPlaylistRow struct { DurationSec int32 CreatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz + Kind string + SystemVariant *string + SeedArtistID pgtype.UUID OwnerUsername string } @@ -159,6 +165,9 @@ func (q *Queries) GetPlaylist(ctx context.Context, id pgtype.UUID) (GetPlaylistR &i.DurationSec, &i.CreatedAt, &i.UpdatedAt, + &i.Kind, + &i.SystemVariant, + &i.SeedArtistID, &i.OwnerUsername, ) return i, err @@ -271,7 +280,7 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID } const listPlaylistsForUser = `-- name: ListPlaylistsForUser :many -SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, u.username AS owner_username +SELECT p.id, p.user_id, p.name, p.description, p.is_public, p.cover_path, p.track_count, p.duration_sec, p.created_at, p.updated_at, p.kind, p.system_variant, p.seed_artist_id, u.username AS owner_username FROM playlists p JOIN users u ON u.id = p.user_id WHERE p.user_id = $1 OR p.is_public = true @@ -289,6 +298,9 @@ type ListPlaylistsForUserRow struct { DurationSec int32 CreatedAt pgtype.Timestamptz UpdatedAt pgtype.Timestamptz + Kind string + SystemVariant *string + SeedArtistID pgtype.UUID OwnerUsername string } @@ -314,6 +326,9 @@ func (q *Queries) ListPlaylistsForUser(ctx context.Context, userID pgtype.UUID) &i.DurationSec, &i.CreatedAt, &i.UpdatedAt, + &i.Kind, + &i.SystemVariant, + &i.SeedArtistID, &i.OwnerUsername, ); err != nil { return nil, err @@ -365,7 +380,7 @@ SET is_public = CASE WHEN $5::boolean THEN $6::boolean ELSE is_public END, updated_at = now() WHERE id = $7 -RETURNING id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at +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 UpdatePlaylistParams struct { @@ -403,6 +418,9 @@ func (q *Queries) UpdatePlaylist(ctx context.Context, arg UpdatePlaylistParams) &i.DurationSec, &i.CreatedAt, &i.UpdatedAt, + &i.Kind, + &i.SystemVariant, + &i.SeedArtistID, ) return i, err } diff --git a/internal/db/dbq/system_playlists.sql.go b/internal/db/dbq/system_playlists.sql.go new file mode 100644 index 00000000..1584bd64 --- /dev/null +++ b/internal/db/dbq/system_playlists.sql.go @@ -0,0 +1,354 @@ +// 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 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 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 +} + +// Powers GET /api/me/playlists?kind=. 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 plays AS ( + SELECT t.artist_id, + COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count + 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 +), +liked AS ( + SELECT artist_id FROM general_likes_artists WHERE user_id = $1 +) +SELECT p.artist_id, + (p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score + FROM plays p + LEFT JOIN liked l ON l.artist_id = p.artist_id + ORDER BY score DESC, p.artist_id + LIMIT 3 +` + +type PickSeedArtistsRow struct { + ArtistID pgtype.UUID + Score int64 +} + +// Top-3 most-engaged distinct artists in the user's last 7 days. +// Score = unskipped-play count + 5 if user has liked the artist. +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); 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) +) +` + +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. +func (q *Queries) PickTopPlayedTrackForArtistByUser(ctx context.Context, arg PickTopPlayedTrackForArtistByUserParams) (interface{}, error) { + row := q.db.QueryRow(ctx, pickTopPlayedTrackForArtistByUser, arg.UserID, arg.ArtistID) + var coalesce interface{} + err := row.Scan(&coalesce) + return coalesce, err +} + +const pickTopPlayedTrackForUser = `-- name: PickTopPlayedTrackForUser :one +SELECT t.id + 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 pe.was_skipped = false + GROUP BY t.id + ORDER BY COUNT(*) DESC, t.id + LIMIT 1 +` + +// For-You seed selection. Returns the user's most-played non-skipped +// track in the last 7 days; tie-break by track_id for determinism. +func (q *Queries) PickTopPlayedTrackForUser(ctx context.Context, userID pgtype.UUID) (pgtype.UUID, error) { + row := q.db.QueryRow(ctx, pickTopPlayedTrackForUser, userID) + var id pgtype.UUID + err := row.Scan(&id) + return id, err +} + +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 +} diff --git a/internal/db/queries/system_playlists.sql b/internal/db/queries/system_playlists.sql new file mode 100644 index 00000000..800df762 --- /dev/null +++ b/internal/db/queries/system_playlists.sql @@ -0,0 +1,141 @@ +-- M7 #352 slice 2: system-generated playlist queries. + +-- name: ListActiveUsersForSystemPlaylists :many +-- Active = had a play in the last 7 days. The cron iterates this list. +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' + ); + +-- name: GetSystemPlaylistRun :one +SELECT user_id, last_run_at, last_run_date, in_flight, last_error + FROM system_playlist_runs + WHERE user_id = $1; + +-- name: TryClaimSystemPlaylistRun :one +-- Atomic test-and-set on in_flight. Returns the row if we won the claim, +-- or pgx.ErrNoRows if someone else is already building. +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; + +-- name: FinishSystemPlaylistRun :exec +-- Mark a successful build complete. +UPDATE system_playlist_runs + SET last_run_at = $2, + last_run_date = $3, + in_flight = false, + last_error = NULL + WHERE user_id = $1; + +-- name: FailSystemPlaylistRun :exec +-- Mark a failed build complete (in_flight=false, error captured). +UPDATE system_playlist_runs + SET in_flight = false, + last_error = $2 + WHERE user_id = $1; + +-- name: ClearStaleSystemPlaylistInFlight :exec +-- Cron startup recovery: clear any in_flight=true rows from a previously +-- crashed process. Safe because at startup, by definition, nothing is +-- still building. +UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true; + +-- name: PickSeedArtists :many +-- Top-3 most-engaged distinct artists in the user's last 7 days. +-- Score = unskipped-play count + 5 if user has liked the artist. +WITH plays AS ( + SELECT t.artist_id, + COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count + 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 +), +liked AS ( + SELECT artist_id FROM general_likes_artists WHERE user_id = $1 +) +SELECT p.artist_id, + (p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score + FROM plays p + LEFT JOIN liked l ON l.artist_id = p.artist_id + ORDER BY score DESC, p.artist_id + LIMIT 3; + +-- name: PickTopPlayedTrackForUser :one +-- For-You seed selection. Returns the user's most-played non-skipped +-- track in the last 7 days; tie-break by track_id for determinism. +SELECT t.id + 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 pe.was_skipped = false + GROUP BY t.id + ORDER BY COUNT(*) DESC, t.id + LIMIT 1; + +-- name: PickTopPlayedTrackForArtistByUser :one +-- "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. +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) +); + +-- name: PickTopAlbumCoverForArtistByUser :one +-- "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). +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; + +-- name: DeleteSystemPlaylistsForUser :exec +-- Atomic-replace step. CASCADE on playlist_tracks.playlist_id handles +-- the join rows. +DELETE FROM playlists WHERE user_id = $1 AND kind = 'system'; + +-- name: ListPlaylistsByUserAndKind :many +-- Powers GET /api/me/playlists?kind=. Sorted by updated_at DESC. +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;