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:
@@ -335,6 +335,30 @@ type PlaySession struct {
|
|||||||
ClientID *string
|
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 {
|
type ScrobbleQueue struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE IF EXISTS playlist_tracks;
|
||||||
|
DROP TABLE IF EXISTS playlists;
|
||||||
@@ -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;
|
||||||
@@ -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