feat(db): playlists schema for M7 #352 slice 1
Migration 0014 adds playlists + playlist_tracks. track_id is nullable with ON DELETE SET NULL — tracks can be removed from the library without silently dropping playlist entries; the denormalized snapshot (title/artist/album/duration) keeps the row legible afterwards. UI renders such rows greyed-out. Indexes: playlists by (user_id, updated_at DESC) and a partial public index for cross-user discovery; playlist_tracks partial index on track_id to support the FK SET NULL lookup. Queries provide CRUD + rollup recompute (track_count, duration_sec) + append/remove primitives. Reorder is service-layer orchestrated via raw tx.Exec; no SQL primitive needed.
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
-- 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.
|
||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
||||
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
|
||||
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;
|
||||
Reference in New Issue
Block a user