Files
minstrel/internal/db/queries/playlists.sql
T
bvandeusen fb4431207d
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m37s
feat(recommendation): For You exploration attribution — taste vs fresh picks
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
2026-07-02 20:39:41 -04:00

118 lines
4.5 KiB
SQL

-- name: CreatePlaylist :one
INSERT INTO playlists (user_id, name, description, is_public)
VALUES ($1, $2, $3, $4)
RETURNING *;
-- name: GetPlaylist :one
SELECT p.*, u.username AS owner_username
FROM playlists p
JOIN users u ON u.id = p.user_id
WHERE p.id = $1;
-- name: ListPlaylistsForUser :many
-- Owner's playlists (any visibility) + other users' public playlists.
-- Ordered by updated_at desc so newly-edited ones float to the top.
SELECT p.*, 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;
-- name: UpdatePlaylist :one
-- 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.
UPDATE playlists
SET
name = CASE WHEN sqlc.arg(update_name)::boolean THEN sqlc.arg(name)::text ELSE name END,
description = CASE WHEN sqlc.arg(update_description)::boolean THEN sqlc.arg(description)::text ELSE description END,
is_public = CASE WHEN sqlc.arg(update_is_public)::boolean THEN sqlc.arg(is_public)::boolean ELSE is_public END,
updated_at = now()
WHERE id = sqlc.arg(id)
RETURNING *;
-- name: UpdatePlaylistRollups :exec
-- Set track_count + duration_sec from a fresh aggregate. Called after
-- every mutation that touches playlist_tracks. Cheap; the table is small.
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;
-- name: SetPlaylistCover :exec
UPDATE playlists SET cover_path = $2, updated_at = now() WHERE id = $1;
-- name: DeletePlaylist :one
-- Returns cover_path so the caller can clean up the cached collage on disk.
DELETE FROM playlists WHERE id = $1
RETURNING id, cover_path;
-- name: ListPlaylistTracks :many
-- 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.
SELECT pt.*,
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;
-- name: AppendPlaylistTrack :one
-- 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).
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
SELECT
sqlc.arg(playlist_id)::uuid,
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
t.id,
t.title,
artists.name,
albums.title,
(t.duration_ms / 1000)::integer,
sqlc.narg(pick_kind)::text
FROM tracks t
JOIN albums ON albums.id = t.album_id
JOIN artists ON artists.id = t.artist_id
WHERE t.id = sqlc.arg(track_id)::uuid
RETURNING *;
-- name: DeletePlaylistTrack :exec
-- 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.
DELETE FROM playlist_tracks
WHERE playlist_id = $1 AND position = $2;
-- name: RenumberPlaylistTracksAfter :exec
-- Used after DeletePlaylistTrack to close the gap.
UPDATE playlist_tracks
SET position = position - 1
WHERE playlist_id = $1 AND position > $2;
-- name: ListAllPlaylistTracksForCollage :many
-- First N tracks for the collage. Uses LEFT JOIN on albums for the
-- cover_path; rows with NULL cover_path get the glyph fallback.
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;
-- name: GetPlaylistsByIDs :many
-- Batched lookup used by /api/library/sync to hydrate upsert payloads
-- (#357). Mirror of GetArtistsByIDs.
SELECT * FROM playlists WHERE id = ANY($1::uuid[]);