From 1226cb758311371aaeb2a2c34777aad5fd747f96 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 10:03:36 -0400 Subject: [PATCH] feat(db): playlists schema for M7 #352 slice 1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/db/dbq/models.go | 24 + internal/db/dbq/playlists.sql.go | 424 ++++++++++++++++++ .../db/migrations/0014_playlists.down.sql | 2 + internal/db/migrations/0014_playlists.up.sql | 39 ++ internal/db/queries/playlists.sql | 110 +++++ 5 files changed, 599 insertions(+) create mode 100644 internal/db/dbq/playlists.sql.go create mode 100644 internal/db/migrations/0014_playlists.down.sql create mode 100644 internal/db/migrations/0014_playlists.up.sql create mode 100644 internal/db/queries/playlists.sql diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 0fa5b8f8..e406323f 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -335,6 +335,30 @@ type PlaySession struct { ClientID *string } +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 +} + +type PlaylistTrack struct { + PlaylistID pgtype.UUID + Position int32 + TrackID pgtype.UUID + Title string + ArtistName string + AlbumTitle string + DurationSec int32 + AddedAt pgtype.Timestamptz +} + type ScrobbleQueue struct { ID pgtype.UUID UserID pgtype.UUID diff --git a/internal/db/dbq/playlists.sql.go b/internal/db/dbq/playlists.sql.go new file mode 100644 index 00000000..750a47cc --- /dev/null +++ b/internal/db/dbq/playlists.sql.go @@ -0,0 +1,424 @@ +// 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) +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 +FROM tracks t +JOIN albums ON albums.id = t.album_id +JOIN artists ON artists.id = t.artist_id +WHERE t.id = $2::uuid +RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at +` + +type AppendPlaylistTrackParams struct { + PlaylistID pgtype.UUID + 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. +func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) { + row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID) + var i PlaylistTrack + err := row.Scan( + &i.PlaylistID, + &i.Position, + &i.TrackID, + &i.Title, + &i.ArtistName, + &i.AlbumTitle, + &i.DurationSec, + &i.AddedAt, + ) + 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 +` + +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, + ) + 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, 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 + 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.OwnerUsername, + ) + return i, err +} + +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, + 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 + 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.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, 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 + 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.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 +` + +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, + ) + 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 +} diff --git a/internal/db/migrations/0014_playlists.down.sql b/internal/db/migrations/0014_playlists.down.sql new file mode 100644 index 00000000..0e7e724d --- /dev/null +++ b/internal/db/migrations/0014_playlists.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS playlist_tracks; +DROP TABLE IF EXISTS playlists; diff --git a/internal/db/migrations/0014_playlists.up.sql b/internal/db/migrations/0014_playlists.up.sql new file mode 100644 index 00000000..21642891 --- /dev/null +++ b/internal/db/migrations/0014_playlists.up.sql @@ -0,0 +1,39 @@ +-- M7 #352 slice 1: playlists CRUD foundation. +-- See docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md. + +CREATE TABLE playlists ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + description text NOT NULL DEFAULT '', + is_public boolean NOT NULL DEFAULT false, + cover_path text, + track_count integer NOT NULL DEFAULT 0, + duration_sec integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX playlists_user_idx ON playlists (user_id, updated_at DESC); +CREATE INDEX playlists_public_idx ON playlists (is_public, updated_at DESC) WHERE is_public; + +-- track_id is nullable + ON DELETE SET NULL so deleting a track from +-- the library doesn't silently drop entries from operators' playlists. +-- Denormalized title/artist/album/duration carry a snapshot so the row +-- is still legible after the upstream track row is gone. +CREATE TABLE playlist_tracks ( + playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, + position integer NOT NULL, + track_id uuid REFERENCES tracks(id) ON DELETE SET NULL, + title text NOT NULL, + artist_name text NOT NULL, + album_title text NOT NULL, + duration_sec integer NOT NULL, + added_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (playlist_id, position) +); + +-- Partial index supports the FK lookup that fires when a track is +-- deleted (ON DELETE SET NULL) — without it, deleting a track triggers +-- a full scan of playlist_tracks. +CREATE INDEX playlist_tracks_track_idx ON playlist_tracks (track_id) WHERE track_id IS NOT NULL; diff --git a/internal/db/queries/playlists.sql b/internal/db/queries/playlists.sql new file mode 100644 index 00000000..afed223a --- /dev/null +++ b/internal/db/queries/playlists.sql @@ -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;