fb4431207d
Milestone #127 step 2 (#1249). For You deliberately blends two populations — a head of top-scored taste picks and a tail sampled from deeper ranking (the freshness injection) — but the metrics judged it as one blob, so its skip rate couldn't distinguish "the taste engine is missing" from "the freshness tax is too high". That number decides the exploration share before we tune it. - Migration 0038: nullable pick_kind ('taste'|'fresh') on both playlist_tracks (stamped at snapshot build) and play_events (frozen at play-ingestion — the snapshot rebuilds daily, so attribution cannot be reconstructed at read time). - Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool fallback is all taste (top-N-by-score IS the taste mechanism). Other variants persist NULL. - Ingestion: for_you plays (live + offline replay) look the track up in the user's current snapshot; not found → unattributed, never guessed. - Metrics: For You's row gains a breakdown (taste / fresh / earlier unattributed plays), parent row stays the sum; web card renders the sub-rows indented with the same baseline deltas + low-data dimming. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
489 lines
13 KiB
Go
489 lines
13 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: playlists.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
|
|
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
|
|
SELECT
|
|
$1::uuid,
|
|
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
|
|
t.id,
|
|
t.title,
|
|
artists.name,
|
|
albums.title,
|
|
(t.duration_ms / 1000)::integer,
|
|
$2::text
|
|
FROM tracks t
|
|
JOIN albums ON albums.id = t.album_id
|
|
JOIN artists ON artists.id = t.artist_id
|
|
WHERE t.id = $3::uuid
|
|
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
|
|
`
|
|
|
|
type AppendPlaylistTrackParams struct {
|
|
PlaylistID pgtype.UUID
|
|
PickKind *string
|
|
TrackID pgtype.UUID
|
|
}
|
|
|
|
// Inserts at the next available position. Snapshot fields are copied
|
|
// from the tracks/albums/artists join at insert time. tracks.duration_ms
|
|
// is converted to seconds for the snapshot. pick_kind is NULL for manual
|
|
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
|
|
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
|
|
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
|
|
var i PlaylistTrack
|
|
err := row.Scan(
|
|
&i.PlaylistID,
|
|
&i.Position,
|
|
&i.TrackID,
|
|
&i.Title,
|
|
&i.ArtistName,
|
|
&i.AlbumTitle,
|
|
&i.DurationSec,
|
|
&i.AddedAt,
|
|
&i.PickKind,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
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, kind, system_variant, seed_artist_id
|
|
`
|
|
|
|
type CreatePlaylistParams struct {
|
|
UserID pgtype.UUID
|
|
Name string
|
|
Description string
|
|
IsPublic bool
|
|
}
|
|
|
|
func (q *Queries) CreatePlaylist(ctx context.Context, arg CreatePlaylistParams) (Playlist, error) {
|
|
row := q.db.QueryRow(ctx, createPlaylist,
|
|
arg.UserID,
|
|
arg.Name,
|
|
arg.Description,
|
|
arg.IsPublic,
|
|
)
|
|
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 deletePlaylist = `-- name: DeletePlaylist :one
|
|
DELETE FROM playlists WHERE id = $1
|
|
RETURNING id, cover_path
|
|
`
|
|
|
|
type DeletePlaylistRow struct {
|
|
ID pgtype.UUID
|
|
CoverPath *string
|
|
}
|
|
|
|
// Returns cover_path so the caller can clean up the cached collage on disk.
|
|
func (q *Queries) DeletePlaylist(ctx context.Context, id pgtype.UUID) (DeletePlaylistRow, error) {
|
|
row := q.db.QueryRow(ctx, deletePlaylist, id)
|
|
var i DeletePlaylistRow
|
|
err := row.Scan(&i.ID, &i.CoverPath)
|
|
return i, err
|
|
}
|
|
|
|
const deletePlaylistTrack = `-- name: DeletePlaylistTrack :exec
|
|
DELETE FROM playlist_tracks
|
|
WHERE playlist_id = $1 AND position = $2
|
|
`
|
|
|
|
type DeletePlaylistTrackParams struct {
|
|
PlaylistID pgtype.UUID
|
|
Position int32
|
|
}
|
|
|
|
// Two-step: delete the row at `position`, then renumber subsequent rows
|
|
// to close the gap. The renumber is a single UPDATE; the service layer
|
|
// runs both in one transaction.
|
|
func (q *Queries) DeletePlaylistTrack(ctx context.Context, arg DeletePlaylistTrackParams) error {
|
|
_, err := q.db.Exec(ctx, deletePlaylistTrack, arg.PlaylistID, arg.Position)
|
|
return err
|
|
}
|
|
|
|
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, 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
|
|
`
|
|
|
|
type GetPlaylistRow 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
|
|
Kind string
|
|
SystemVariant *string
|
|
SeedArtistID pgtype.UUID
|
|
OwnerUsername string
|
|
}
|
|
|
|
func (q *Queries) GetPlaylist(ctx context.Context, id pgtype.UUID) (GetPlaylistRow, error) {
|
|
row := q.db.QueryRow(ctx, getPlaylist, id)
|
|
var i GetPlaylistRow
|
|
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,
|
|
&i.OwnerUsername,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getPlaylistsByIDs = `-- name: GetPlaylistsByIDs :many
|
|
SELECT id, user_id, name, description, is_public, cover_path, track_count, duration_sec, created_at, updated_at, kind, system_variant, seed_artist_id FROM playlists WHERE id = ANY($1::uuid[])
|
|
`
|
|
|
|
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
|
// (#357). Mirror of GetArtistsByIDs.
|
|
func (q *Queries) GetPlaylistsByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([]Playlist, error) {
|
|
rows, err := q.db.Query(ctx, getPlaylistsByIDs, dollar_1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []Playlist
|
|
for rows.Next() {
|
|
var i Playlist
|
|
if err := rows.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,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listAllPlaylistTracksForCollage = `-- name: ListAllPlaylistTracksForCollage :many
|
|
SELECT pt.position,
|
|
albums.cover_art_path AS album_cover_path
|
|
FROM playlist_tracks pt
|
|
LEFT JOIN tracks t ON t.id = pt.track_id
|
|
LEFT JOIN albums ON albums.id = t.album_id
|
|
WHERE pt.playlist_id = $1
|
|
ORDER BY pt.position
|
|
LIMIT $2
|
|
`
|
|
|
|
type ListAllPlaylistTracksForCollageParams struct {
|
|
PlaylistID pgtype.UUID
|
|
Limit int32
|
|
}
|
|
|
|
type ListAllPlaylistTracksForCollageRow struct {
|
|
Position int32
|
|
AlbumCoverPath *string
|
|
}
|
|
|
|
// First N tracks for the collage. Uses LEFT JOIN on albums for the
|
|
// cover_path; rows with NULL cover_path get the glyph fallback.
|
|
func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListAllPlaylistTracksForCollageParams) ([]ListAllPlaylistTracksForCollageRow, error) {
|
|
rows, err := q.db.Query(ctx, listAllPlaylistTracksForCollage, arg.PlaylistID, arg.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListAllPlaylistTracksForCollageRow
|
|
for rows.Next() {
|
|
var i ListAllPlaylistTracksForCollageRow
|
|
if err := rows.Scan(&i.Position, &i.AlbumCoverPath); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
|
|
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
|
|
t.id AS live_track_id,
|
|
albums.id AS album_id,
|
|
artists.id AS artist_id
|
|
FROM playlist_tracks pt
|
|
LEFT JOIN tracks t ON t.id = pt.track_id
|
|
LEFT JOIN albums ON albums.id = t.album_id
|
|
LEFT JOIN artists ON artists.id = t.artist_id
|
|
WHERE pt.playlist_id = $1
|
|
ORDER BY pt.position
|
|
`
|
|
|
|
type ListPlaylistTracksRow struct {
|
|
PlaylistID pgtype.UUID
|
|
Position int32
|
|
TrackID pgtype.UUID
|
|
Title string
|
|
ArtistName string
|
|
AlbumTitle string
|
|
DurationSec int32
|
|
AddedAt pgtype.Timestamptz
|
|
PickKind *string
|
|
LiveTrackID pgtype.UUID
|
|
AlbumID pgtype.UUID
|
|
ArtistID pgtype.UUID
|
|
}
|
|
|
|
// Joined to tracks for the live track id (the service layer derives the
|
|
// stream URL from it); LEFT JOIN preserves the row when track_id is NULL
|
|
// (track was removed from the library). The denormalized snapshot fields
|
|
// on playlist_tracks remain authoritative for title/artist/album text.
|
|
func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID) ([]ListPlaylistTracksRow, error) {
|
|
rows, err := q.db.Query(ctx, listPlaylistTracks, playlistID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListPlaylistTracksRow
|
|
for rows.Next() {
|
|
var i ListPlaylistTracksRow
|
|
if err := rows.Scan(
|
|
&i.PlaylistID,
|
|
&i.Position,
|
|
&i.TrackID,
|
|
&i.Title,
|
|
&i.ArtistName,
|
|
&i.AlbumTitle,
|
|
&i.DurationSec,
|
|
&i.AddedAt,
|
|
&i.PickKind,
|
|
&i.LiveTrackID,
|
|
&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 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, 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
|
|
ORDER BY p.updated_at DESC
|
|
`
|
|
|
|
type ListPlaylistsForUserRow 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
|
|
Kind string
|
|
SystemVariant *string
|
|
SeedArtistID pgtype.UUID
|
|
OwnerUsername string
|
|
}
|
|
|
|
// Owner's playlists (any visibility) + other users' public playlists.
|
|
// Ordered by updated_at desc so newly-edited ones float to the top.
|
|
func (q *Queries) ListPlaylistsForUser(ctx context.Context, userID pgtype.UUID) ([]ListPlaylistsForUserRow, error) {
|
|
rows, err := q.db.Query(ctx, listPlaylistsForUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListPlaylistsForUserRow
|
|
for rows.Next() {
|
|
var i ListPlaylistsForUserRow
|
|
if err := rows.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,
|
|
&i.OwnerUsername,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const renumberPlaylistTracksAfter = `-- name: RenumberPlaylistTracksAfter :exec
|
|
UPDATE playlist_tracks
|
|
SET position = position - 1
|
|
WHERE playlist_id = $1 AND position > $2
|
|
`
|
|
|
|
type RenumberPlaylistTracksAfterParams struct {
|
|
PlaylistID pgtype.UUID
|
|
Position int32
|
|
}
|
|
|
|
// Used after DeletePlaylistTrack to close the gap.
|
|
func (q *Queries) RenumberPlaylistTracksAfter(ctx context.Context, arg RenumberPlaylistTracksAfterParams) error {
|
|
_, err := q.db.Exec(ctx, renumberPlaylistTracksAfter, arg.PlaylistID, arg.Position)
|
|
return err
|
|
}
|
|
|
|
const setPlaylistCover = `-- name: SetPlaylistCover :exec
|
|
UPDATE playlists SET cover_path = $2, updated_at = now() WHERE id = $1
|
|
`
|
|
|
|
type SetPlaylistCoverParams struct {
|
|
ID pgtype.UUID
|
|
CoverPath *string
|
|
}
|
|
|
|
func (q *Queries) SetPlaylistCover(ctx context.Context, arg SetPlaylistCoverParams) error {
|
|
_, err := q.db.Exec(ctx, setPlaylistCover, arg.ID, arg.CoverPath)
|
|
return err
|
|
}
|
|
|
|
const updatePlaylist = `-- name: UpdatePlaylist :one
|
|
UPDATE playlists
|
|
SET
|
|
name = CASE WHEN $1::boolean THEN $2::text ELSE name END,
|
|
description = CASE WHEN $3::boolean THEN $4::text ELSE description END,
|
|
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, kind, system_variant, seed_artist_id
|
|
`
|
|
|
|
type UpdatePlaylistParams struct {
|
|
UpdateName bool
|
|
Name string
|
|
UpdateDescription bool
|
|
Description string
|
|
UpdateIsPublic bool
|
|
IsPublic bool
|
|
ID pgtype.UUID
|
|
}
|
|
|
|
// Updates only the fields whose corresponding `updateX` flag is true.
|
|
// The flags let the service layer keep PATCH semantics (only-touch-what-the-caller-sent)
|
|
// without writing N variants.
|
|
func (q *Queries) UpdatePlaylist(ctx context.Context, arg UpdatePlaylistParams) (Playlist, error) {
|
|
row := q.db.QueryRow(ctx, updatePlaylist,
|
|
arg.UpdateName,
|
|
arg.Name,
|
|
arg.UpdateDescription,
|
|
arg.Description,
|
|
arg.UpdateIsPublic,
|
|
arg.IsPublic,
|
|
arg.ID,
|
|
)
|
|
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 updatePlaylistRollups = `-- name: UpdatePlaylistRollups :exec
|
|
UPDATE playlists
|
|
SET
|
|
track_count = (SELECT COUNT(*) FROM playlist_tracks pt WHERE pt.playlist_id = $1),
|
|
duration_sec = (SELECT COALESCE(SUM(pt.duration_sec), 0) FROM playlist_tracks pt WHERE pt.playlist_id = $1),
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
`
|
|
|
|
// Set track_count + duration_sec from a fresh aggregate. Called after
|
|
// every mutation that touches playlist_tracks. Cheap; the table is small.
|
|
func (q *Queries) UpdatePlaylistRollups(ctx context.Context, playlistID pgtype.UUID) error {
|
|
_, err := q.db.Exec(ctx, updatePlaylistRollups, playlistID)
|
|
return err
|
|
}
|