11 tasks: migration 0014 + sqlc queries (T1), Service CRUD methods (T2), track operations (T3), cover-collage generator (T4), 9 HTTP handlers + service wiring (T5), frontend api helper + types + qk (T6), PlaylistCard (T7), PlaylistTrackRow with drag handle + soft-mark strikethrough (T8), AddToPlaylistMenu wired into TrackMenu (T9), /playlists index page (T10), /playlists/[id] detail with drag-reorder (T11). Soft-mark cascade preserved end-to-end: schema (track_id nullable + ON DELETE SET NULL with denormalized snapshot columns), service (no cascade on track delete; rows persist), UI (greyed-out + strikethrough for null track_id rows). Cover collage is synchronous inline 2x2 JPEG via image/jpeg stdlib; SVG fallback rasterization is a follow-up.
120 KiB
M7 — Playlists CRUD (slice 1 of #352) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Land manually-curated playlists end-to-end: schema (migration 0014), Go service + collage generator, /api/playlists* routes, /playlists list page, /playlists/{id} detail page with drag-to-reorder, and the "Add to playlist…" submenu wired into <TrackMenu>'s reserved slot.
Architecture: New internal/playlists package owns CRUD + permissions + cover-collage generation. Synchronous inline collage rendering via Go's image/jpeg stdlib on every track-list mutation; cached on disk at data/playlist_covers/{id}.jpg. Frontend uses TanStack Query for state, native HTML5 drag-and-drop for reorder. Soft-mark cascade (track_id nullable + ON DELETE SET NULL) preserves a denormalized snapshot when the upstream track row is deleted, so playlists never silently shrink.
Tech Stack: Go 1.23 · pgx/v5 + sqlc · Postgres (migration 0014) · image/jpeg stdlib · SvelteKit 2 / Svelte 5 (runes) · TanStack Query · Vitest · Lucide icons · FabledSword design tokens.
Spec: docs/superpowers/specs/2026-05-03-m7-playlists-crud-design.md. Read it first — every decision is explained there.
Memory dependencies:
feedback_no_in_task_tests.md— HARD RULE. Do NOT runnpm test,npm run check,go test,flutter test, etc. inside any task. Write the test file as code, write the production code, commit. CI runs the suite.feedback_v1_is_full_product.md— slice 1 ships complete. Slices 2 (system-generated mixes) + 3 (home-page row) are sibling spec/plan cycles, also in v1.project_design_system.md— FabledSword tokens; Lucide icons; sentence case; Moss/Bronze/Oxblood for action buttons (NEVER accent).project_no_github.md— Forgejo MCP for any PR/issue ops; neverghCLI.project_subsonic_legacy.md—/api/*only; no/rest/*parity.project_git_workflow.md— commit ondev; PR tomainseparately.
File map
Backend — create
internal/db/migrations/0014_playlists.up.sqlinternal/db/migrations/0014_playlists.down.sqlinternal/db/queries/playlists.sql— Create / Get / List / Update / Delete + tracks insert / delete / reorderinternal/playlists/service.go—ServicewithCreate,Get,List,Update,Delete,AppendTracks,RemoveTrack,Reorder. Typed errorsErrNotFound,ErrForbidden,ErrInvalidInput,ErrTrackNotFound.internal/playlists/service_test.gointernal/playlists/collage.go—GenerateCollage(ctx, q, playlistID, dataDir) (relPath string, err). 2×2 grid viaimage/jpeg; missing cells filled withalbum-fallback.svgrasterized to 300×300.internal/playlists/collage_test.gointernal/api/playlists.go— 9 HTTP handlersinternal/api/playlists_test.go
Backend — modify
internal/db/dbq/*— regenerated bysqlc generateinternal/api/api.go—Mountgains aplaylistsSvc *playlists.Servicearg; add 9 routes insideauthed.Group(...)internal/server/server.go— constructplaylistsSvc := playlists.NewService(...)and pass toapi.Mount
Frontend — create
web/src/lib/api/playlists.ts—listPlaylists,getPlaylist,createPlaylist,updatePlaylist,deletePlaylist,appendTracks,reorderPlaylist,removeTrack. PluscreatePlaylistsQuery()andcreatePlaylistQuery(id)factories.web/src/lib/api/playlists.test.tsweb/src/lib/components/PlaylistCard.svelte— collage + name + track_count + (creator name when not owned).web/src/lib/components/PlaylistCard.test.tsweb/src/lib/components/AddToPlaylistMenu.svelte— submenu mounted from<TrackMenu>'s "Add to playlist…" entry.web/src/lib/components/AddToPlaylistMenu.test.tsweb/src/lib/components/PlaylistTrackRow.svelte— variant of<TrackRow>for the detail page; drag handle + strikethrough fortrack_id=NULL.web/src/lib/components/PlaylistTrackRow.test.tsweb/src/routes/playlists/+page.svelte(rewrite of placeholder)web/src/routes/playlists/[id]/+page.svelteweb/src/routes/playlists/playlists.test.tsweb/src/routes/playlists/[id]/playlist.test.ts
Frontend — modify
web/src/lib/components/TrackMenu.svelte— replace the disabled "Add to playlist…" entry with a real opener that mounts<AddToPlaylistMenu>.web/src/lib/components/TrackMenu.test.ts— drop the "Add to playlist… is disabled" assertion; replace with one verifying the entry is enabled.web/src/lib/api/queries.ts— addqk.playlists(),qk.playlist(id).web/src/lib/api/types.ts— addPlaylist,PlaylistDetail,PlaylistTracktypes.
Task list
Task 1 — Migration 0014 + sqlc queries
Files:
-
Create:
internal/db/migrations/0014_playlists.up.sql -
Create:
internal/db/migrations/0014_playlists.down.sql -
Create:
internal/db/queries/playlists.sql -
Regenerate:
internal/db/dbq/*viasqlc generate -
Step 1.1: Write
internal/db/migrations/0014_playlists.up.sql
-- 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;
- Step 1.2: Write
internal/db/migrations/0014_playlists.down.sql
DROP TABLE IF EXISTS playlist_tracks;
DROP TABLE IF EXISTS playlists;
- Step 1.3: Write
internal/db/queries/playlists.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 WHERE playlist_id = $1),
duration_sec = (SELECT COALESCE(SUM(duration_sec), 0) FROM playlist_tracks WHERE 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 stream_url; 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.
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_sec
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: ReplacePlaylistTracksOrder :exec
-- Reorder strategy: delete + reinsert is awkward because the snapshot
-- fields need preserving. Instead, use a temporary offset to push every
-- row out of the position range, then write the new positions.
-- Implementation note: the service layer drives this with two UPDATE
-- statements per track in a transaction (offset by +10000, then back to
-- the new position). See service.go for the orchestration.
SELECT 1; -- placeholder; service uses ad-hoc UPDATEs
-- 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;
Note on the Update query: sqlc supports sqlc.arg(name)::type syntax to give parameters explicit names. The CASE WHEN updateX::boolean THEN ... pattern is the project's idiomatic PATCH.
The ReplacePlaylistTracksOrder is a placeholder — sqlc generates a no-op function. The actual reorder logic lives in the service layer using ad-hoc UPDATE statements wrapped in a transaction (it's not a single declarative SQL statement). The placeholder exists so the operation has a name in the codebase and reviewers can find the logic.
- Step 1.4: Run sqlc generate
cd /home/bvandeusen/Nextcloud/Projects/Minstrel/minstrel
sqlc generate
Expected: internal/db/dbq/playlists.sql.go created with the new query methods.
- Step 1.5: Commit
git add internal/db/migrations/0014_playlists.up.sql internal/db/migrations/0014_playlists.down.sql internal/db/queries/playlists.sql internal/db/dbq/
git commit -F - <<'EOF'
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/reorder primitives. Reorder is service-layer
orchestrated; the SQL placeholder is intentional.
EOF
Task 2 — Playlists service: CRUD methods
Files:
- Create:
internal/playlists/service.go - Create:
internal/playlists/service_test.go(CRUD-only cases for this task; track-ops tests land in Task 3)
This task lands Service with Create, Get, List, Update, Delete. Track-list mutation methods come in Task 3 to keep the diff focused.
- Step 2.1: Write
internal/playlists/service.go
package playlists
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Typed errors. Mapped to wire codes by the API layer.
var (
ErrNotFound = errors.New("playlist not found")
ErrForbidden = errors.New("forbidden")
ErrInvalidInput = errors.New("invalid input")
ErrTrackNotFound = errors.New("track not found")
)
type Service struct {
pool *pgxpool.Pool
logger *slog.Logger
dataDir string // root of `data/`; collages live under `<dataDir>/playlist_covers/`
}
func NewService(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) *Service {
if logger == nil {
logger = slog.Default()
}
return &Service{pool: pool, logger: logger, dataDir: dataDir}
}
// PlaylistRow mirrors the wire shape returned by Get/List/Create/Update.
// The denormalized owner_username comes from the JOIN in the query.
type PlaylistRow struct {
ID pgtype.UUID
UserID pgtype.UUID
OwnerUsername string
Name string
Description string
IsPublic bool
CoverPath *string
TrackCount int32
DurationSec int32
CreatedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
}
// PlaylistDetail extends PlaylistRow with the track list. The slice is
// always fully loaded (no pagination in slice 1 — playlists are small).
type PlaylistDetail struct {
PlaylistRow
Tracks []PlaylistTrack
}
// PlaylistTrack is one row in a playlist's track list. TrackID is nil
// when the upstream track has been removed from the library.
type PlaylistTrack struct {
Position int32
TrackID *pgtype.UUID
AlbumID *pgtype.UUID
ArtistID *pgtype.UUID
Title string
ArtistName string
AlbumTitle string
DurationSec int32
AddedAt pgtype.Timestamptz
}
// --- CRUD ---
func (s *Service) Create(ctx context.Context, userID pgtype.UUID, name, description string, isPublic bool) (*PlaylistRow, error) {
if name == "" {
return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
}
q := dbq.New(s.pool)
row, err := q.CreatePlaylist(ctx, dbq.CreatePlaylistParams{
UserID: userID,
Name: name,
Description: description,
IsPublic: isPublic,
})
if err != nil {
return nil, fmt.Errorf("create playlist: %w", err)
}
// Re-fetch to get owner_username via the join.
full, err := q.GetPlaylist(ctx, row.ID)
if err != nil {
return nil, fmt.Errorf("get-after-create: %w", err)
}
return playlistFromGetRow(full), nil
}
func (s *Service) Get(ctx context.Context, callerID, playlistID pgtype.UUID) (*PlaylistDetail, error) {
q := dbq.New(s.pool)
row, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get playlist: %w", err)
}
if !row.IsPublic && !pgtypeUUIDEqual(row.UserID, callerID) {
return nil, ErrForbidden
}
tracksRows, err := q.ListPlaylistTracks(ctx, playlistID)
if err != nil {
return nil, fmt.Errorf("list tracks: %w", err)
}
tracks := make([]PlaylistTrack, 0, len(tracksRows))
for _, t := range tracksRows {
pt := PlaylistTrack{
Position: t.Position,
Title: t.Title,
ArtistName: t.ArtistName,
AlbumTitle: t.AlbumTitle,
DurationSec: t.DurationSec,
AddedAt: t.AddedAt,
}
if t.TrackID.Valid {
id := t.TrackID
pt.TrackID = &id
}
if t.AlbumID.Valid {
id := t.AlbumID
pt.AlbumID = &id
}
if t.ArtistID.Valid {
id := t.ArtistID
pt.ArtistID = &id
}
tracks = append(tracks, pt)
}
return &PlaylistDetail{
PlaylistRow: *playlistFromGetRow(row),
Tracks: tracks,
}, nil
}
func (s *Service) List(ctx context.Context, callerID pgtype.UUID) ([]PlaylistRow, error) {
q := dbq.New(s.pool)
rows, err := q.ListPlaylistsForUser(ctx, callerID)
if err != nil {
return nil, fmt.Errorf("list playlists: %w", err)
}
out := make([]PlaylistRow, 0, len(rows))
for _, r := range rows {
out = append(out, *playlistFromListRow(r))
}
return out, nil
}
// UpdateInput captures the optional fields PATCH can change. nil means
// "don't touch this field."
type UpdateInput struct {
Name *string
Description *string
IsPublic *bool
}
func (s *Service) Update(ctx context.Context, callerID, playlistID pgtype.UUID, in UpdateInput) (*PlaylistRow, error) {
q := dbq.New(s.pool)
existing, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get-before-update: %w", err)
}
if !pgtypeUUIDEqual(existing.UserID, callerID) {
return nil, ErrForbidden
}
params := dbq.UpdatePlaylistParams{
ID: playlistID,
UpdateName: in.Name != nil,
UpdateDescription: in.Description != nil,
UpdateIsPublic: in.IsPublic != nil,
}
if in.Name != nil {
if *in.Name == "" {
return nil, fmt.Errorf("%w: name cannot be empty", ErrInvalidInput)
}
params.Name = *in.Name
}
if in.Description != nil {
params.Description = *in.Description
}
if in.IsPublic != nil {
params.IsPublic = *in.IsPublic
}
updated, err := q.UpdatePlaylist(ctx, params)
if err != nil {
return nil, fmt.Errorf("update playlist: %w", err)
}
full, err := q.GetPlaylist(ctx, updated.ID)
if err != nil {
return nil, fmt.Errorf("get-after-update: %w", err)
}
return playlistFromGetRow(full), nil
}
func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID) error {
q := dbq.New(s.pool)
existing, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("get-before-delete: %w", err)
}
if !pgtypeUUIDEqual(existing.UserID, callerID) {
return ErrForbidden
}
deleted, err := q.DeletePlaylist(ctx, playlistID)
if err != nil {
return fmt.Errorf("delete playlist: %w", err)
}
// Best-effort cover-file cleanup. ENOENT is fine.
if deleted.CoverPath != nil && *deleted.CoverPath != "" {
full := filepath.Join(s.dataDir, *deleted.CoverPath)
if rerr := os.Remove(full); rerr != nil && !errors.Is(rerr, os.ErrNotExist) {
s.logger.Warn("playlist delete: cover file remove failed",
"path", full, "playlist_id", playlistID, "err", rerr)
}
}
return nil
}
// --- helpers ---
func playlistFromGetRow(r dbq.GetPlaylistRow) *PlaylistRow {
return &PlaylistRow{
ID: r.ID,
UserID: r.UserID,
OwnerUsername: r.OwnerUsername,
Name: r.Name,
Description: r.Description,
IsPublic: r.IsPublic,
CoverPath: r.CoverPath,
TrackCount: r.TrackCount,
DurationSec: r.DurationSec,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
func playlistFromListRow(r dbq.ListPlaylistsForUserRow) *PlaylistRow {
return &PlaylistRow{
ID: r.ID,
UserID: r.UserID,
OwnerUsername: r.OwnerUsername,
Name: r.Name,
Description: r.Description,
IsPublic: r.IsPublic,
CoverPath: r.CoverPath,
TrackCount: r.TrackCount,
DurationSec: r.DurationSec,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
func pgtypeUUIDEqual(a, b pgtype.UUID) bool {
if !a.Valid || !b.Valid {
return false
}
return a.Bytes == b.Bytes
}
If sqlc generates field names in a different shape (e.g., Mbid vs Mbid), adjust the row-mapping helpers. Read dbq/playlists.sql.go after Step 1.4 to see what was actually generated.
If dbq.GetPlaylistRow has CoverPath as pgtype.Text instead of *string, adapt by checking .Valid and dereferencing .String. Same applies to all the other fields the queries return.
- Step 2.2: Write
internal/playlists/service_test.go(CRUD cases)
package playlists_test
import (
"context"
"errors"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
// Helpers seedUser, newPool follow the patterns in
// internal/lidarrquarantine/service_test.go and internal/tracks/service_test.go.
// Copy them into a service_test_helpers_test.go in this package, or inline.
func TestCreate_HappyPath(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
dir := t.TempDir()
svc := playlists.NewService(pool, nil, dir)
row, err := svc.Create(context.Background(), user.ID, "Saturday morning", "Mellow", false)
if err != nil {
t.Fatalf("Create: %v", err)
}
if row.Name != "Saturday morning" {
t.Errorf("name = %q, want Saturday morning", row.Name)
}
if row.IsPublic {
t.Error("IsPublic = true, want false (default)")
}
if row.OwnerUsername != "alice" {
t.Errorf("OwnerUsername = %q, want alice", row.OwnerUsername)
}
if row.TrackCount != 0 || row.DurationSec != 0 {
t.Errorf("rollups should start at 0; got count=%d duration=%d", row.TrackCount, row.DurationSec)
}
}
func TestCreate_EmptyNameRejected(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
svc := playlists.NewService(pool, nil, t.TempDir())
_, err := svc.Create(context.Background(), user.ID, "", "", false)
if !errors.Is(err, playlists.ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput", err)
}
}
func TestGet_NotFound(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
svc := playlists.NewService(pool, nil, t.TempDir())
_, err := svc.Get(context.Background(), user.ID, randomUUID())
if !errors.Is(err, playlists.ErrNotFound) {
t.Errorf("err = %v, want ErrNotFound", err)
}
}
func TestGet_Forbidden_PrivatePlaylistFromOtherUser(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), alice.ID, "Private", "", false)
_, err := svc.Get(context.Background(), bob.ID, pl.ID)
if !errors.Is(err, playlists.ErrForbidden) {
t.Errorf("err = %v, want ErrForbidden", err)
}
}
func TestGet_Public_VisibleToOtherUser(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), alice.ID, "Public mix", "", true)
got, err := svc.Get(context.Background(), bob.ID, pl.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.Name != "Public mix" {
t.Errorf("name = %q", got.Name)
}
}
func TestList_OwnAndPublic(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
svc := playlists.NewService(pool, nil, t.TempDir())
_, _ = svc.Create(context.Background(), alice.ID, "Alice private", "", false)
_, _ = svc.Create(context.Background(), alice.ID, "Alice public", "", true)
_, _ = svc.Create(context.Background(), bob.ID, "Bob private", "", false)
_, _ = svc.Create(context.Background(), bob.ID, "Bob public", "", true)
// Alice sees: her own (both) + Bob's public = 3.
rows, err := svc.List(context.Background(), alice.ID)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(rows) != 3 {
t.Errorf("alice's list = %d rows, want 3", len(rows))
}
}
func TestUpdate_OwnerOnly(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), alice.ID, "Mine", "", false)
rename := "Renamed"
_, err := svc.Update(context.Background(), bob.ID, pl.ID, playlists.UpdateInput{Name: &rename})
if !errors.Is(err, playlists.ErrForbidden) {
t.Errorf("err = %v, want ErrForbidden", err)
}
updated, err := svc.Update(context.Background(), alice.ID, pl.ID, playlists.UpdateInput{Name: &rename})
if err != nil {
t.Fatalf("owner Update: %v", err)
}
if updated.Name != "Renamed" {
t.Errorf("name = %q, want Renamed", updated.Name)
}
}
func TestDelete_RemovesCoverFile(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
dir := t.TempDir()
svc := playlists.NewService(pool, nil, dir)
pl, _ := svc.Create(context.Background(), user.ID, "Will be deleted", "", false)
// Simulate a previously-generated cover by creating the file.
coverDir := filepath.Join(dir, "playlist_covers")
_ = os.MkdirAll(coverDir, 0o755)
relPath := filepath.Join("playlist_covers", uuidString(pl.ID)+".jpg")
full := filepath.Join(dir, relPath)
if err := os.WriteFile(full, []byte("jpeg-bytes"), 0o644); err != nil {
t.Fatalf("seed cover file: %v", err)
}
// Set the cover_path on the playlist row.
if _, err := pool.Exec(context.Background(),
`UPDATE playlists SET cover_path = $1 WHERE id = $2`, relPath, pl.ID); err != nil {
t.Fatalf("set cover_path: %v", err)
}
if err := svc.Delete(context.Background(), user.ID, pl.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, statErr := os.Stat(full); !errors.Is(statErr, os.ErrNotExist) {
t.Errorf("cover file still on disk after delete")
}
}
The seed helpers (newPool, seedUser, randomUUID, uuidString) — copy from internal/tracks/service_test.go (which copied from internal/lidarrquarantine/service_test.go). Tests are integration-style and require MINSTREL_TEST_DATABASE_URL.
- Step 2.3: Commit
git add internal/playlists/service.go internal/playlists/service_test.go
git commit -F - <<'EOF'
feat(playlists): Service with CRUD methods (M7 #352 slice 1)
Create / Get / List / Update / Delete with the visibility model from
the spec: private by default, owner-only mutations, public read for
non-owners. Update uses sqlc's CASE-WHEN-flag pattern for PATCH-style
partial updates without writing N variants.
Delete cleans up the cached cover file from disk best-effort. Track
operations (Append/Remove/Reorder) and the collage generator land
in subsequent tasks.
EOF
Task 3 — Playlists service: track operations
Files:
-
Modify:
internal/playlists/service.go— addAppendTracks,RemoveTrack,Reorder -
Modify:
internal/playlists/service_test.go— track-ops cases -
Step 3.1: Append
AppendTracks,RemoveTrack,Reordertointernal/playlists/service.go
Add after the Delete method:
// AppendTracks adds the given track ids at the end of the playlist,
// preserving order. Snapshot fields (title, artist, album, duration_sec)
// are populated from the tracks/albums/artists join at insert time.
// Triggers cover regeneration after.
func (s *Service) AppendTracks(ctx context.Context, callerID, playlistID pgtype.UUID, trackIDs []pgtype.UUID) error {
if len(trackIDs) == 0 {
return fmt.Errorf("%w: at least one track id required", ErrInvalidInput)
}
q := dbq.New(s.pool)
pl, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("get playlist: %w", err)
}
if !pgtypeUUIDEqual(pl.UserID, callerID) {
return ErrForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
tq := dbq.New(tx)
for _, tid := range trackIDs {
_, ierr := tq.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
PlaylistID: playlistID,
TrackID: tid,
})
if ierr != nil {
// pgx wraps a "no rows returned" into something we have to detect;
// the AppendPlaylistTrack query uses INSERT ... SELECT FROM tracks WHERE
// id = X — if X doesn't exist, no row inserts and the :one query errors.
if errors.Is(ierr, pgx.ErrNoRows) {
return ErrTrackNotFound
}
return fmt.Errorf("append track: %w", ierr)
}
}
if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
return fmt.Errorf("update rollups: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
// Regenerate cover (synchronous). Failure is logged but not returned —
// the playlist mutation already committed.
if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
}
return nil
}
// RemoveTrack deletes the row at `position` and renumbers subsequent
// rows to close the gap. Triggers cover regeneration after.
func (s *Service) RemoveTrack(ctx context.Context, callerID, playlistID pgtype.UUID, position int32) error {
q := dbq.New(s.pool)
pl, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("get playlist: %w", err)
}
if !pgtypeUUIDEqual(pl.UserID, callerID) {
return ErrForbidden
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
tq := dbq.New(tx)
if err := tq.DeletePlaylistTrack(ctx, dbq.DeletePlaylistTrackParams{
PlaylistID: playlistID,
Position: position,
}); err != nil {
return fmt.Errorf("delete track row: %w", err)
}
if err := tq.RenumberPlaylistTracksAfter(ctx, dbq.RenumberPlaylistTracksAfterParams{
PlaylistID: playlistID,
Position: position,
}); err != nil {
return fmt.Errorf("renumber: %w", err)
}
if err := tq.UpdatePlaylistRollups(ctx, playlistID); err != nil {
return fmt.Errorf("update rollups: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
}
return nil
}
// Reorder atomically rewrites all positions to the supplied permutation.
// orderedPositions is the new positional order: if the playlist has 4
// tracks at positions [0, 1, 2, 3] and the caller wants the order
// (old[3], old[0], old[1], old[2]), they send [3, 0, 1, 2].
func (s *Service) Reorder(ctx context.Context, callerID, playlistID pgtype.UUID, orderedPositions []int32) error {
q := dbq.New(s.pool)
pl, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("get playlist: %w", err)
}
if !pgtypeUUIDEqual(pl.UserID, callerID) {
return ErrForbidden
}
// Validate: orderedPositions must be a permutation of 0..N-1 where
// N = len(orderedPositions) AND match the number of rows in the playlist.
if int32(len(orderedPositions)) != pl.TrackCount {
return fmt.Errorf("%w: expected %d positions, got %d", ErrInvalidInput, pl.TrackCount, len(orderedPositions))
}
seen := make(map[int32]struct{}, len(orderedPositions))
for _, p := range orderedPositions {
if p < 0 || p >= pl.TrackCount {
return fmt.Errorf("%w: position %d out of range", ErrInvalidInput, p)
}
if _, dup := seen[p]; dup {
return fmt.Errorf("%w: position %d duplicated", ErrInvalidInput, p)
}
seen[p] = struct{}{}
}
// Rewrite strategy: bump every row's position by +10000 first (out of
// range of valid positions and the unique PK) then write the new
// positions one by one. Single transaction.
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx,
`UPDATE playlist_tracks SET position = position + 10000 WHERE playlist_id = $1`,
playlistID); err != nil {
return fmt.Errorf("offset positions: %w", err)
}
for newPos, oldPos := range orderedPositions {
if _, err := tx.Exec(ctx,
`UPDATE playlist_tracks SET position = $1 WHERE playlist_id = $2 AND position = $3`,
int32(newPos), playlistID, oldPos+10000); err != nil {
return fmt.Errorf("write new position: %w", err)
}
}
if err := dbq.New(tx).UpdatePlaylistRollups(ctx, playlistID); err != nil {
return fmt.Errorf("update rollups: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
// First-4 changed? Reorder always potentially affects the first 4
// (the collage uses positions 0..3). Regenerate unconditionally —
// cheap, and reasoning about which moves matter is more error-prone
// than just doing the work.
if _, cerr := GenerateCollage(ctx, s.pool, playlistID, s.dataDir); cerr != nil {
s.logger.Warn("collage regeneration failed", "playlist_id", playlistID, "err", cerr)
}
return nil
}
The tx.Exec for the +10000 offset uses raw SQL because there isn't a sqlc query for it (it's a service-layer-only mechanism). That's fine; it's a one-line statement that doesn't need codegen overhead.
Note: GenerateCollage doesn't exist yet — it lands in Task 4. Compilation will fail at this point, but the next task fixes that. The implementer should complete Task 3 + Task 4 before pushing.
- Step 3.2: Append track-ops tests to
internal/playlists/service_test.go
func TestAppendTracks_Snapshot(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
track := seedTrack(t, pool, "Roygbiv", "Boards of Canada")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
if err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{track.ID}); err != nil {
t.Fatalf("AppendTracks: %v", err)
}
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
if len(got.Tracks) != 1 {
t.Fatalf("Tracks length = %d, want 1", len(got.Tracks))
}
if got.Tracks[0].Title != "Roygbiv" {
t.Errorf("Title snapshot = %q, want Roygbiv", got.Tracks[0].Title)
}
if got.Tracks[0].ArtistName != "Boards of Canada" {
t.Errorf("ArtistName snapshot = %q, want Boards of Canada", got.Tracks[0].ArtistName)
}
if got.TrackCount != 1 {
t.Errorf("rollup TrackCount = %d, want 1", got.TrackCount)
}
}
func TestAppendTracks_OwnerOnly(t *testing.T) {
pool := newPool(t)
alice := seedUser(t, pool, "alice")
bob := seedUser(t, pool, "bob")
track := seedTrack(t, pool, "T", "A")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), alice.ID, "Mine", "", true)
err := svc.AppendTracks(context.Background(), bob.ID, pl.ID, []pgtype.UUID{track.ID})
if !errors.Is(err, playlists.ErrForbidden) {
t.Errorf("err = %v, want ErrForbidden", err)
}
}
func TestAppendTracks_NonExistentTrack(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
err := svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{randomUUID()})
if !errors.Is(err, playlists.ErrTrackNotFound) {
t.Errorf("err = %v, want ErrTrackNotFound", err)
}
}
func TestRemoveTrack_RenumbersPositions(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
t1 := seedTrack(t, pool, "Track 1", "Artist")
t2 := seedTrack(t, pool, "Track 2", "Artist")
t3 := seedTrack(t, pool, "Track 3", "Artist")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID})
// Remove the middle one (position 1).
if err := svc.RemoveTrack(context.Background(), user.ID, pl.ID, 1); err != nil {
t.Fatalf("RemoveTrack: %v", err)
}
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
if len(got.Tracks) != 2 {
t.Fatalf("Tracks length = %d, want 2", len(got.Tracks))
}
if got.Tracks[0].Title != "Track 1" {
t.Errorf("position 0 = %q, want Track 1", got.Tracks[0].Title)
}
if got.Tracks[1].Title != "Track 3" {
t.Errorf("position 1 = %q, want Track 3 (renumbered from 2)", got.Tracks[1].Title)
}
}
func TestReorder_Permutation(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
t1 := seedTrack(t, pool, "A", "Artist")
t2 := seedTrack(t, pool, "B", "Artist")
t3 := seedTrack(t, pool, "C", "Artist")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{t1.ID, t2.ID, t3.ID})
// Reverse order: send [2, 1, 0].
if err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{2, 1, 0}); err != nil {
t.Fatalf("Reorder: %v", err)
}
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
wantOrder := []string{"C", "B", "A"}
for i, t := range got.Tracks {
if t.Title != wantOrder[i] {
t.Errorf("position %d = %q, want %q", i, t.Title, wantOrder[i])
}
}
}
func TestReorder_RejectsNonPermutation(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
tk := seedTrack(t, pool, "T", "A")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})
// 2 positions for a 1-track playlist — invalid.
err := svc.Reorder(context.Background(), user.ID, pl.ID, []int32{0, 1})
if !errors.Is(err, playlists.ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput (length mismatch)", err)
}
// duplicate position — invalid.
pl2, _ := svc.Create(context.Background(), user.ID, "Two", "", false)
t2 := seedTrack(t, pool, "T2", "A")
_ = svc.AppendTracks(context.Background(), user.ID, pl2.ID, []pgtype.UUID{tk.ID, t2.ID})
err = svc.Reorder(context.Background(), user.ID, pl2.ID, []int32{0, 0})
if !errors.Is(err, playlists.ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput (duplicate)", err)
}
}
func TestSnapshotPersistsAfterUpstreamTrackDelete(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
tk := seedTrack(t, pool, "Doomed track", "Artist")
svc := playlists.NewService(pool, nil, t.TempDir())
pl, _ := svc.Create(context.Background(), user.ID, "Test", "", false)
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})
// Delete the track row directly (simulating Lidarr re-import / file removal).
if _, err := pool.Exec(context.Background(), `DELETE FROM tracks WHERE id = $1`, tk.ID); err != nil {
t.Fatalf("delete track: %v", err)
}
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
if len(got.Tracks) != 1 {
t.Fatalf("Tracks length = %d, want 1 (soft-mark not silent-cascade)", len(got.Tracks))
}
row := got.Tracks[0]
if row.TrackID != nil {
t.Error("TrackID should be nil after upstream delete")
}
if row.Title != "Doomed track" {
t.Errorf("Snapshot Title = %q, want Doomed track", row.Title)
}
}
seedTrack(t, pool, title, artist) creates a track + its album + its artist. Implement as a helper in service_test_helpers_test.go (or inline) that does the three INSERTs and returns a struct with the IDs. Match the existing project pattern from internal/lidarrquarantine/service_test.go.
- Step 3.3: Commit (combined with Task 4 — see Task 4 step 4.4)
This task's commit waits for Task 4 to land the GenerateCollage symbol that the service references. The implementer should keep the changes uncommitted until both tasks compile together, then commit as one atomic change in Task 4.
Task 4 — Cover collage generator
Files:
-
Create:
internal/playlists/collage.go -
Create:
internal/playlists/collage_test.go -
Step 4.1: Write
internal/playlists/collage.go
package playlists
import (
"bytes"
"context"
"fmt"
"image"
"image/color"
"image/draw"
"image/jpeg"
"os"
"path/filepath"
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
_ "image/png" // for decoding existing PNG covers
_ "image/jpeg" // for decoding existing JPEG covers
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
const (
collageCellSize = 300 // px per cell, 2x2 → 600x600 output
collageOutputDim = collageCellSize * 2
collageQuality = 85
)
// GenerateCollage composes a 600x600 JPEG from the first 4 contributing
// tracks' album covers. Missing covers (no upstream album, empty
// cover_art_path, file unreadable) get the album-fallback glyph in their
// cell. Writes to <dataDir>/playlist_covers/<playlist_id>.jpg and
// updates playlists.cover_path. Returns the relative path written.
func GenerateCollage(ctx context.Context, pool *pgxpool.Pool, playlistID pgtype.UUID, dataDir string) (string, error) {
q := dbq.New(pool)
rows, err := q.ListAllPlaylistTracksForCollage(ctx, dbq.ListAllPlaylistTracksForCollageParams{
PlaylistID: playlistID,
Limit: 4,
})
if err != nil {
return "", fmt.Errorf("list collage tracks: %w", err)
}
if len(rows) == 0 {
// Empty playlist: clear cover_path and remove any stale file.
if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{
ID: playlistID,
CoverPath: nil,
}); err != nil {
return "", fmt.Errorf("clear cover_path: %w", err)
}
_ = os.Remove(filepath.Join(dataDir, "playlist_covers", uuidToFilename(playlistID)+".jpg"))
return "", nil
}
out := image.NewRGBA(image.Rect(0, 0, collageOutputDim, collageOutputDim))
// Fill with FabledSword `iron` (#1E2228) so any drawing gaps look intentional.
draw.Draw(out, out.Bounds(), &image.Uniform{C: color.RGBA{0x1E, 0x22, 0x28, 0xFF}}, image.Point{}, draw.Src)
for cell := 0; cell < 4; cell++ {
var coverPath string
if cell < len(rows) && rows[cell].AlbumCoverPath != nil {
coverPath = *rows[cell].AlbumCoverPath
}
img := loadCellImage(coverPath, dataDir)
// Place the cell. Cell coordinates: top-left (0,0), top-right (1,0), bottom-left (0,1), bottom-right (1,1).
col := cell % 2
row := cell / 2
dest := image.Rect(col*collageCellSize, row*collageCellSize, (col+1)*collageCellSize, (row+1)*collageCellSize)
drawScaled(out, dest, img)
}
// Encode + write.
if err := os.MkdirAll(filepath.Join(dataDir, "playlist_covers"), 0o755); err != nil {
return "", fmt.Errorf("mkdir playlist_covers: %w", err)
}
relPath := filepath.Join("playlist_covers", uuidToFilename(playlistID)+".jpg")
full := filepath.Join(dataDir, relPath)
tmp := full + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return "", fmt.Errorf("create collage file: %w", err)
}
if err := jpeg.Encode(f, out, &jpeg.Options{Quality: collageQuality}); err != nil {
_ = f.Close()
_ = os.Remove(tmp)
return "", fmt.Errorf("encode jpeg: %w", err)
}
if err := f.Close(); err != nil {
return "", fmt.Errorf("close collage file: %w", err)
}
if err := os.Rename(tmp, full); err != nil {
return "", fmt.Errorf("rename: %w", err)
}
if err := q.SetPlaylistCover(ctx, dbq.SetPlaylistCoverParams{
ID: playlistID,
CoverPath: stringPtr(relPath),
}); err != nil {
return "", fmt.Errorf("set cover_path: %w", err)
}
return relPath, nil
}
// loadCellImage tries to load the album cover at the given path. On any
// failure (empty path, missing file, decode error), returns the rendered
// fallback glyph as a 300x300 image.
func loadCellImage(coverPath, dataDir string) image.Image {
if coverPath == "" {
return fallbackGlyph()
}
full := coverPath
if !filepath.IsAbs(coverPath) {
full = filepath.Join(dataDir, coverPath)
}
f, err := os.Open(full)
if err != nil {
return fallbackGlyph()
}
defer f.Close()
img, _, err := image.Decode(f)
if err != nil {
return fallbackGlyph()
}
return img
}
// fallbackGlyph returns a 300x300 image filled with the FabledSword
// "slate" surface tint and a centered solid box approximating the
// album-fallback glyph. Slice 1 trade-off: rasterizing the actual SVG
// at runtime requires a third-party SVG renderer (oksvg / etc.) and
// adds dependency + complexity. The solid placeholder reads as
// "this cell intentionally empty" without committing to SVG plumbing.
// A follow-up task can swap in real SVG rasterization.
func fallbackGlyph() image.Image {
img := image.NewRGBA(image.Rect(0, 0, collageCellSize, collageCellSize))
bg := color.RGBA{0x2C, 0x31, 0x3A, 0xFF} // FabledSword slate
fg := color.RGBA{0x9C, 0x9A, 0x92, 0xFF} // FabledSword ash
draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
// Center 100x100 box as a placeholder mark.
center := image.Rect(100, 100, 200, 200)
draw.Draw(img, center, &image.Uniform{C: fg}, image.Point{}, draw.Src)
return img
}
// drawScaled copies src into dst.Rect, scaling with simple nearest-neighbor.
// stdlib lacks high-quality scaling; nearest-neighbor is fine for a
// 600x600 output where each cell is 300x300 — most album covers are
// already 300-1500 pixels and the visual loss is minor.
func drawScaled(dst draw.Image, r image.Rectangle, src image.Image) {
srcBounds := src.Bounds()
for y := r.Min.Y; y < r.Max.Y; y++ {
for x := r.Min.X; x < r.Max.X; x++ {
sx := srcBounds.Min.X + (x-r.Min.X)*srcBounds.Dx()/r.Dx()
sy := srcBounds.Min.Y + (y-r.Min.Y)*srcBounds.Dy()/r.Dy()
dst.Set(x, y, src.At(sx, sy))
}
}
}
func stringPtr(s string) *string { return &s }
func uuidToFilename(u pgtype.UUID) string {
// Format the UUID with hyphens for human readability of the file
// name, but strip any path-traversal characters defensively.
s := pgtypeUUIDString(u)
return strings.NewReplacer("/", "_", "..", "_").Replace(s)
}
func pgtypeUUIDString(u pgtype.UUID) string {
if !u.Valid {
return ""
}
b := u.Bytes
return fmt.Sprintf("%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7],
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15])
}
The fallback glyph is a solid placeholder rather than rasterized SVG. This is a deliberate slice-1 trade-off documented in the function's doc comment. A follow-up task can wire in the actual album-fallback.svg.
If the project already has a UUID-to-string helper somewhere (e.g., uuidToString in internal/api/convert.go), reuse it instead of pgtypeUUIDString. Read convert.go first.
If dbq.ListAllPlaylistTracksForCollageRow exposes AlbumCoverPath as pgtype.Text rather than *string, adapt the conditional accordingly.
- Step 4.2: Write
internal/playlists/collage_test.go
package playlists_test
import (
"context"
"image"
_ "image/jpeg"
"os"
"path/filepath"
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
func TestGenerateCollage_EmptyPlaylist(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
dir := t.TempDir()
svc := playlists.NewService(pool, nil, dir)
pl, _ := svc.Create(context.Background(), user.ID, "Empty", "", false)
relPath, err := playlists.GenerateCollage(context.Background(), pool, pl.ID, dir)
if err != nil {
t.Fatalf("GenerateCollage: %v", err)
}
if relPath != "" {
t.Errorf("relPath = %q, want \"\" for empty playlist", relPath)
}
}
func TestGenerateCollage_OneTrack(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
tk := seedTrack(t, pool, "Roygbiv", "BoC")
dir := t.TempDir()
svc := playlists.NewService(pool, nil, dir)
pl, _ := svc.Create(context.Background(), user.ID, "One", "", false)
_ = svc.AppendTracks(context.Background(), user.ID, pl.ID, []pgtype.UUID{tk.ID})
// Append already triggers GenerateCollage; verify the file landed.
got, _ := svc.Get(context.Background(), user.ID, pl.ID)
if got.CoverPath == nil || *got.CoverPath == "" {
t.Fatalf("CoverPath empty after append")
}
full := filepath.Join(dir, *got.CoverPath)
f, err := os.Open(full)
if err != nil {
t.Fatalf("open collage: %v", err)
}
defer f.Close()
img, format, err := image.Decode(f)
if err != nil {
t.Fatalf("decode collage: %v", err)
}
if format != "jpeg" {
t.Errorf("format = %q, want jpeg", format)
}
if img.Bounds().Dx() != 600 || img.Bounds().Dy() != 600 {
t.Errorf("dimensions = %dx%d, want 600x600", img.Bounds().Dx(), img.Bounds().Dy())
}
}
- Step 4.3: Wire
os.MkdirAllimport
Already in step 4.1.
- Step 4.4: Commit Task 3 + Task 4 together
git add internal/playlists/service.go internal/playlists/service_test.go internal/playlists/collage.go internal/playlists/collage_test.go
git commit -F - <<'EOF'
feat(playlists): track operations + cover-collage generator
AppendTracks / RemoveTrack / Reorder run in single transactions and
update the denormalized rollups (track_count, duration_sec). Reorder
uses a +10000 offset to bump every row out of the position range
before writing the new positions, sidestepping PK conflicts during
rewrite.
GenerateCollage composes a 600x600 JPEG from the first 4 album
covers, with a slate-tinted fallback for missing cells. Synchronous,
called inline after every mutating operation. SVG-rasterized fallback
is a follow-up — slice 1 ships with a solid placeholder.
EOF
Task 5 — API handlers + service wiring
Files:
-
Create:
internal/api/playlists.go -
Create:
internal/api/playlists_test.go -
Modify:
internal/api/api.go—MountgainsplaylistsSvc *playlists.Service; register 9 routes -
Modify:
internal/server/server.go— constructplaylistsSvc; pass toMount -
Step 5.1: Write
internal/api/playlists.go
package api
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
// --- wire shapes ---
type playlistRowView struct {
ID string `json:"id"`
UserID string `json:"user_id"`
OwnerUsername string `json:"owner_username"`
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
CoverURL string `json:"cover_url"`
TrackCount int32 `json:"track_count"`
DurationSec int32 `json:"duration_sec"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type playlistTrackView struct {
Position int32 `json:"position"`
TrackID *string `json:"track_id"`
AlbumID *string `json:"album_id"`
ArtistID *string `json:"artist_id"`
Title string `json:"title"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
DurationSec int32 `json:"duration_sec"`
StreamURL *string `json:"stream_url"`
AddedAt string `json:"added_at"`
}
type playlistDetailView struct {
playlistRowView
Tracks []playlistTrackView `json:"tracks"`
}
type listPlaylistsResponse struct {
Owned []playlistRowView `json:"owned"`
Public []playlistRowView `json:"public"`
}
// --- handlers ---
// GET /api/playlists
func (h *handlers) handleListPlaylists(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
rows, err := h.playlists.List(r.Context(), caller.ID)
if err != nil {
h.logger.Error("api: list playlists failed", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "list failed")
return
}
resp := listPlaylistsResponse{Owned: []playlistRowView{}, Public: []playlistRowView{}}
for _, r := range rows {
v := playlistRowToView(&r)
if pgtypeUUIDEqual(r.UserID, caller.ID) {
resp.Owned = append(resp.Owned, v)
} else {
resp.Public = append(resp.Public, v)
}
}
writeJSON(w, http.StatusOK, resp)
}
type createPlaylistBody struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
}
// POST /api/playlists
func (h *handlers) handleCreatePlaylist(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
var body createPlaylistBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
row, err := h.playlists.Create(r.Context(), caller.ID, body.Name, body.Description, body.IsPublic)
if err != nil {
writePlaylistErr(w, err, "create")
return
}
writeJSON(w, http.StatusOK, playlistRowToView(row))
}
// GET /api/playlists/{id}
func (h *handlers) handleGetPlaylist(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
playlistID, err := parseUUIDParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
return
}
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
if err != nil {
writePlaylistErr(w, err, "get")
return
}
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}
type updatePlaylistBody struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
IsPublic *bool `json:"is_public,omitempty"`
}
// PATCH /api/playlists/{id}
func (h *handlers) handleUpdatePlaylist(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
playlistID, err := parseUUIDParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
return
}
var body updatePlaylistBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
row, err := h.playlists.Update(r.Context(), caller.ID, playlistID, playlists.UpdateInput{
Name: body.Name,
Description: body.Description,
IsPublic: body.IsPublic,
})
if err != nil {
writePlaylistErr(w, err, "update")
return
}
writeJSON(w, http.StatusOK, playlistRowToView(row))
}
// DELETE /api/playlists/{id}
func (h *handlers) handleDeletePlaylist(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
playlistID, err := parseUUIDParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
return
}
if err := h.playlists.Delete(r.Context(), caller.ID, playlistID); err != nil {
writePlaylistErr(w, err, "delete")
return
}
w.WriteHeader(http.StatusNoContent)
}
type appendTracksBody struct {
TrackIDs []string `json:"track_ids"`
}
// POST /api/playlists/{id}/tracks
func (h *handlers) handleAppendTracks(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
playlistID, err := parseUUIDParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
return
}
var body appendTracksBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
trackIDs := make([]pgtype.UUID, 0, len(body.TrackIDs))
for _, s := range body.TrackIDs {
u, ok := parseUUID(s)
if !ok {
writeErr(w, http.StatusBadRequest, "bad_request", fmt.Sprintf("invalid track id %q", s))
return
}
trackIDs = append(trackIDs, u)
}
if err := h.playlists.AppendTracks(r.Context(), caller.ID, playlistID, trackIDs); err != nil {
writePlaylistErr(w, err, "append tracks")
return
}
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
if err != nil {
writePlaylistErr(w, err, "get-after-append")
return
}
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}
// DELETE /api/playlists/{id}/tracks/{position}
func (h *handlers) handleRemovePlaylistTrack(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
playlistID, err := parseUUIDParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
return
}
posStr := chi.URLParam(r, "position")
pos64, err := strconv.ParseInt(posStr, 10, 32)
if err != nil || pos64 < 0 {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid position")
return
}
if err := h.playlists.RemoveTrack(r.Context(), caller.ID, playlistID, int32(pos64)); err != nil {
writePlaylistErr(w, err, "remove track")
return
}
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
if err != nil {
writePlaylistErr(w, err, "get-after-remove")
return
}
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}
type reorderTracksBody struct {
OrderedPositions []int32 `json:"ordered_positions"`
}
// PUT /api/playlists/{id}/tracks
func (h *handlers) handleReorderPlaylist(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
playlistID, err := parseUUIDParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
return
}
var body reorderTracksBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid JSON body")
return
}
if err := h.playlists.Reorder(r.Context(), caller.ID, playlistID, body.OrderedPositions); err != nil {
writePlaylistErr(w, err, "reorder")
return
}
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
if err != nil {
writePlaylistErr(w, err, "get-after-reorder")
return
}
writeJSON(w, http.StatusOK, playlistDetailToView(detail))
}
// GET /api/playlists/{id}/cover
func (h *handlers) handleGetPlaylistCover(w http.ResponseWriter, r *http.Request) {
caller, ok := auth.UserFromContext(r.Context())
if !ok {
writeErr(w, http.StatusUnauthorized, "unauthenticated", "no session")
return
}
playlistID, err := parseUUIDParam(r, "id")
if err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid playlist id")
return
}
detail, err := h.playlists.Get(r.Context(), caller.ID, playlistID)
if err != nil {
writePlaylistErr(w, err, "get cover")
return
}
if detail.CoverPath == nil || *detail.CoverPath == "" {
writeErr(w, http.StatusNotFound, "not_found", "no cover")
return
}
full := filepath.Join(h.dataDir, *detail.CoverPath)
http.ServeFile(w, r, full)
}
// --- helpers ---
func writePlaylistErr(w http.ResponseWriter, err error, op string) {
switch {
case errors.Is(err, playlists.ErrNotFound):
writeErr(w, http.StatusNotFound, "not_found", "playlist not found")
case errors.Is(err, playlists.ErrForbidden):
writeErr(w, http.StatusForbidden, "not_authorized", "you don't own this playlist")
case errors.Is(err, playlists.ErrInvalidInput):
writeErr(w, http.StatusBadRequest, "bad_request", err.Error())
case errors.Is(err, playlists.ErrTrackNotFound):
writeErr(w, http.StatusBadRequest, "bad_request", "one of the supplied track ids does not exist")
default:
writeErr(w, http.StatusInternalServerError, "server_error", op+" failed")
}
}
func playlistRowToView(r *playlists.PlaylistRow) playlistRowView {
v := playlistRowView{
ID: uuidToString(r.ID),
UserID: uuidToString(r.UserID),
OwnerUsername: r.OwnerUsername,
Name: r.Name,
Description: r.Description,
IsPublic: r.IsPublic,
TrackCount: r.TrackCount,
DurationSec: r.DurationSec,
CreatedAt: r.CreatedAt.Time.Format(timeFormat),
UpdatedAt: r.UpdatedAt.Time.Format(timeFormat),
}
if r.CoverPath != nil && *r.CoverPath != "" {
v.CoverURL = "/api/playlists/" + v.ID + "/cover"
}
return v
}
func playlistDetailToView(d *playlists.PlaylistDetail) playlistDetailView {
out := playlistDetailView{
playlistRowView: playlistRowToView(&d.PlaylistRow),
Tracks: make([]playlistTrackView, 0, len(d.Tracks)),
}
for _, t := range d.Tracks {
v := playlistTrackView{
Position: t.Position,
Title: t.Title,
ArtistName: t.ArtistName,
AlbumTitle: t.AlbumTitle,
DurationSec: t.DurationSec,
AddedAt: t.AddedAt.Time.Format(timeFormat),
}
if t.TrackID != nil {
s := uuidToString(*t.TrackID)
v.TrackID = &s
streamURL := "/api/tracks/" + s + "/stream"
v.StreamURL = &streamURL
}
if t.AlbumID != nil {
s := uuidToString(*t.AlbumID)
v.AlbumID = &s
}
if t.ArtistID != nil {
s := uuidToString(*t.ArtistID)
v.ArtistID = &s
}
out.Tracks = append(out.Tracks, v)
}
return out
}
Read internal/api/convert.go to confirm the names and signatures of uuidToString, parseUUID, parseUUIDParam, pgtypeUUIDEqual, timeFormat, writeErr, writeJSON. Adapt if any differ from the placeholders above. The h.dataDir field is added in step 5.4 below.
- Step 5.2: Write
internal/api/playlists_test.go
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestPlaylists_CreateThenGet(t *testing.T) {
srv, _ := newTestServer(t)
user := seedAdminUser(t, srv.pool) // any authenticated user works for non-admin endpoints
body := strings.NewReader(`{"name":"Saturday morning","description":"Mellow","is_public":false}`)
req := httptest.NewRequest(http.MethodPost, "/api/playlists", body)
req.Header.Set("Content-Type", "application/json")
req = withSession(req, user)
rr := httptest.NewRecorder()
srv.handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("create status = %d, body = %s", rr.Code, rr.Body.String())
}
var created struct {
ID string `json:"id"`
}
_ = json.Unmarshal(rr.Body.Bytes(), &created)
if created.ID == "" {
t.Fatal("created.id empty")
}
// Get by id.
req2 := httptest.NewRequest(http.MethodGet, "/api/playlists/"+created.ID, nil)
req2 = withSession(req2, user)
rr2 := httptest.NewRecorder()
srv.handler.ServeHTTP(rr2, req2)
if rr2.Code != http.StatusOK {
t.Fatalf("get status = %d, body = %s", rr2.Code, rr2.Body.String())
}
if !strings.Contains(rr2.Body.String(), `"name":"Saturday morning"`) {
t.Errorf("get body missing name: %s", rr2.Body.String())
}
}
func TestPlaylists_PatchOwnerOnly(t *testing.T) {
srv, _ := newTestServer(t)
alice := seedUser(t, srv.pool, "alice")
bob := seedUser(t, srv.pool, "bob")
create := strings.NewReader(`{"name":"Mine","is_public":true}`)
req := httptest.NewRequest(http.MethodPost, "/api/playlists", create)
req.Header.Set("Content-Type", "application/json")
req = withSession(req, alice)
rr := httptest.NewRecorder()
srv.handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("create: %d %s", rr.Code, rr.Body.String())
}
var created struct{ ID string }
_ = json.Unmarshal(rr.Body.Bytes(), &created)
// Bob tries to PATCH alice's playlist.
patchBody := strings.NewReader(`{"name":"Hijacked"}`)
pr := httptest.NewRequest(http.MethodPatch, "/api/playlists/"+created.ID, patchBody)
pr.Header.Set("Content-Type", "application/json")
pr = withSession(pr, bob)
prr := httptest.NewRecorder()
srv.handler.ServeHTTP(prr, pr)
if prr.Code != http.StatusForbidden {
t.Errorf("non-owner patch status = %d, want 403; body = %s", prr.Code, prr.Body.String())
}
}
func TestPlaylists_AppendThenReorder(t *testing.T) {
srv, _ := newTestServer(t)
user := seedUser(t, srv.pool, "alice")
t1 := seedTrack(t, srv.pool, "Track 1", "Artist")
t2 := seedTrack(t, srv.pool, "Track 2", "Artist")
t3 := seedTrack(t, srv.pool, "Track 3", "Artist")
// Create.
req := httptest.NewRequest(http.MethodPost, "/api/playlists", strings.NewReader(`{"name":"R"}`))
req.Header.Set("Content-Type", "application/json")
req = withSession(req, user)
rr := httptest.NewRecorder()
srv.handler.ServeHTTP(rr, req)
var created struct{ ID string }
_ = json.Unmarshal(rr.Body.Bytes(), &created)
// Append 3 tracks.
appendBody, _ := json.Marshal(map[string]any{"track_ids": []string{
uuidString(t1.ID), uuidString(t2.ID), uuidString(t3.ID),
}})
ar := httptest.NewRequest(http.MethodPost, "/api/playlists/"+created.ID+"/tracks", bytes.NewReader(appendBody))
ar.Header.Set("Content-Type", "application/json")
ar = withSession(ar, user)
arr := httptest.NewRecorder()
srv.handler.ServeHTTP(arr, ar)
if arr.Code != http.StatusOK {
t.Fatalf("append: %d %s", arr.Code, arr.Body.String())
}
// Reorder: reverse.
reorderBody := strings.NewReader(`{"ordered_positions":[2,1,0]}`)
rrq := httptest.NewRequest(http.MethodPut, "/api/playlists/"+created.ID+"/tracks", reorderBody)
rrq.Header.Set("Content-Type", "application/json")
rrq = withSession(rrq, user)
rrr := httptest.NewRecorder()
srv.handler.ServeHTTP(rrr, rrq)
if rrr.Code != http.StatusOK {
t.Fatalf("reorder: %d %s", rrr.Code, rrr.Body.String())
}
// Verify by re-reading.
gr := httptest.NewRequest(http.MethodGet, "/api/playlists/"+created.ID, nil)
gr = withSession(gr, user)
grr := httptest.NewRecorder()
srv.handler.ServeHTTP(grr, gr)
body := grr.Body.String()
// Track 3 should now be at position 0; Track 1 at position 2.
pos3 := strings.Index(body, `"title":"Track 3"`)
pos1 := strings.Index(body, `"title":"Track 1"`)
if pos3 == -1 || pos1 == -1 || pos3 > pos1 {
t.Errorf("after reverse, Track 3 should appear before Track 1 in JSON; body = %s", body)
}
}
The newTestServer, seedUser, seedAdminUser, withSession, seedTrack, uuidString helpers exist or land in this slice's test fixtures (read internal/api/admin_tracks_test.go for the patterns). Match the existing harness.
- Step 5.3: Modify
internal/api/api.go— register routes + add service field
Read internal/api/api.go. The Mount function takes a sequence of services. Add playlistsSvc *playlists.Service as the next parameter (after tracksSvc *tracks.Service). Add playlists *playlists.Service and dataDir string fields to the handlers struct. Pass them through in the constructor.
Then add the routes. Find the existing authed.Group(...) block and append:
authed.Get("/playlists", h.handleListPlaylists)
authed.Post("/playlists", h.handleCreatePlaylist)
authed.Get("/playlists/{id}", h.handleGetPlaylist)
authed.Patch("/playlists/{id}", h.handleUpdatePlaylist)
authed.Delete("/playlists/{id}", h.handleDeletePlaylist)
authed.Post("/playlists/{id}/tracks", h.handleAppendTracks)
authed.Delete("/playlists/{id}/tracks/{position}", h.handleRemovePlaylistTrack)
authed.Put("/playlists/{id}/tracks", h.handleReorderPlaylist)
authed.Get("/playlists/{id}/cover", h.handleGetPlaylistCover)
Add the import "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" to the file.
- Step 5.4: Modify
internal/server/server.go— construct service
Find the lidarrQuar / tracksSvc construction block. Add:
playlistsSvc := playlists.NewService(s.Pool, s.Logger, s.DataDir)
(s.DataDir is the existing field that points at the configured data/ directory. If it's named differently — e.g., s.Cfg.DataDir — adapt accordingly. Read the file first.)
Pass playlistsSvc to api.Mount(...) as the new last argument.
Add the import "git.fabledsword.com/bvandeusen/minstrel/internal/playlists".
- Step 5.5: Commit
git add internal/api/playlists.go internal/api/playlists_test.go internal/api/api.go internal/server/server.go
git commit -F - <<'EOF'
feat(api): /api/playlists* handlers for M7 #352 slice 1
9 handlers covering create / get / list / update / delete / append /
remove / reorder / cover. Permissions enforced in the service layer
(ErrForbidden -> 403 not_authorized) so the handler is a thin
HTTP-shape adapter. /cover serves the cached collage from disk via
http.ServeFile; 404 when cover_path is nil.
Wire codes use the project's flat envelope: not_found, not_authorized,
unauthenticated, bad_request, server_error.
EOF
Task 6 — Frontend API helper + types + query keys
Files:
-
Create:
web/src/lib/api/playlists.ts -
Create:
web/src/lib/api/playlists.test.ts -
Modify:
web/src/lib/api/types.ts— addPlaylist,PlaylistDetail,PlaylistTrack -
Modify:
web/src/lib/api/queries.ts— addqk.playlists(),qk.playlist(id) -
Step 6.1: Add types to
web/src/lib/api/types.ts
Append (or add near the existing entity types):
export type Playlist = {
id: string;
user_id: string;
owner_username: string;
name: string;
description: string;
is_public: boolean;
cover_url: string; // empty string when no cover; UI renders glyph
track_count: number;
duration_sec: number;
created_at: string;
updated_at: string;
};
export type PlaylistTrack = {
position: number;
track_id: string | null; // null when upstream track was removed
album_id: string | null;
artist_id: string | null;
title: string;
artist_name: string;
album_title: string;
duration_sec: number;
stream_url: string | null;
added_at: string;
};
export type PlaylistDetail = Playlist & {
tracks: PlaylistTrack[];
};
- Step 6.2: Add query keys to
web/src/lib/api/queries.ts
Find the qk namespace and append:
playlists: () => ['playlists'] as const,
playlist: (id: string) => ['playlist', id] as const,
- Step 6.3: Write
web/src/lib/api/playlists.ts
import { createQuery } from '@tanstack/svelte-query';
import { apiFetch } from './client';
import { qk } from './queries';
import type { Playlist, PlaylistDetail } from './types';
type ListPlaylistsResponse = {
owned: Playlist[];
public: Playlist[];
};
export async function listPlaylists(): Promise<ListPlaylistsResponse> {
return (await apiFetch('/api/playlists', { method: 'GET' })) as ListPlaylistsResponse;
}
export async function getPlaylist(id: string): Promise<PlaylistDetail> {
return (await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { method: 'GET' })) as PlaylistDetail;
}
export type CreatePlaylistInput = {
name: string;
description?: string;
is_public?: boolean;
};
export async function createPlaylist(input: CreatePlaylistInput): Promise<Playlist> {
return (await apiFetch('/api/playlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input)
})) as Playlist;
}
export type UpdatePlaylistInput = {
name?: string;
description?: string;
is_public?: boolean;
};
export async function updatePlaylist(id: string, input: UpdatePlaylistInput): Promise<Playlist> {
return (await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input)
})) as Playlist;
}
export async function deletePlaylist(id: string): Promise<void> {
await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export async function appendTracks(playlistID: string, trackIDs: string[]): Promise<PlaylistDetail> {
return (await apiFetch(`/api/playlists/${encodeURIComponent(playlistID)}/tracks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_ids: trackIDs })
})) as PlaylistDetail;
}
export async function removePlaylistTrack(playlistID: string, position: number): Promise<PlaylistDetail> {
return (await apiFetch(
`/api/playlists/${encodeURIComponent(playlistID)}/tracks/${position}`,
{ method: 'DELETE' }
)) as PlaylistDetail;
}
export async function reorderPlaylist(playlistID: string, orderedPositions: number[]): Promise<PlaylistDetail> {
return (await apiFetch(`/api/playlists/${encodeURIComponent(playlistID)}/tracks`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ordered_positions: orderedPositions })
})) as PlaylistDetail;
}
export function createPlaylistsQuery() {
return createQuery({
queryKey: qk.playlists(),
queryFn: listPlaylists,
staleTime: 30_000
});
}
export function createPlaylistQuery(id: string) {
return createQuery({
queryKey: qk.playlist(id),
queryFn: () => getPlaylist(id),
staleTime: 30_000
});
}
Read web/src/lib/api/client.ts to confirm apiFetch's return type (likely Promise<unknown>) and adjust the casts. The as Playlist etc. casts mirror the project convention you can see in web/src/lib/api/admin/tracks.ts (Task 4 of #372).
- Step 6.4: Write
web/src/lib/api/playlists.test.ts
import { describe, expect, test, afterEach, vi } from 'vitest';
import {
listPlaylists,
getPlaylist,
createPlaylist,
updatePlaylist,
deletePlaylist,
appendTracks,
removePlaylistTrack,
reorderPlaylist
} from './playlists';
function stubFetch(status: number, body: unknown) {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' }
})
)
);
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('playlists API helper', () => {
test('listPlaylists GETs /api/playlists', async () => {
stubFetch(200, { owned: [], public: [] });
const r = await listPlaylists();
expect(r.owned).toEqual([]);
expect(r.public).toEqual([]);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toBe('/api/playlists');
expect(call[1].method).toBe('GET');
});
test('createPlaylist POSTs JSON body', async () => {
stubFetch(200, { id: 'p1', name: 'Test' });
const r = await createPlaylist({ name: 'Test' });
expect(r.id).toBe('p1');
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[1].method).toBe('POST');
expect(JSON.parse(call[1].body as string)).toEqual({ name: 'Test' });
});
test('reorderPlaylist PUTs ordered_positions', async () => {
stubFetch(200, { id: 'p1', tracks: [] });
await reorderPlaylist('p1', [2, 1, 0]);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toBe('/api/playlists/p1/tracks');
expect(call[1].method).toBe('PUT');
expect(JSON.parse(call[1].body as string)).toEqual({ ordered_positions: [2, 1, 0] });
});
test('removePlaylistTrack DELETEs by position', async () => {
stubFetch(200, { id: 'p1', tracks: [] });
await removePlaylistTrack('p1', 3);
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(call[0]).toBe('/api/playlists/p1/tracks/3');
expect(call[1].method).toBe('DELETE');
});
test('not_found surfaces as ApiError', async () => {
stubFetch(404, { error: 'not_found' });
await expect(getPlaylist('missing')).rejects.toMatchObject({ code: 'not_found' });
});
});
- Step 6.5: Commit
git add web/src/lib/api/playlists.ts web/src/lib/api/playlists.test.ts web/src/lib/api/types.ts web/src/lib/api/queries.ts
git commit -F - <<'EOF'
feat(web/api): playlists helper + types + query keys (M7 #352 slice 1)
Wire shapes mirror the server's snake_case envelope. URL helpers use
the existing apiFetch + ApiError surface so error codes (not_found,
not_authorized, bad_request, server_error) propagate via the same
copyForCode chain as everything else.
EOF
Task 7 — <PlaylistCard> component
Files:
-
Create:
web/src/lib/components/PlaylistCard.svelte -
Create:
web/src/lib/components/PlaylistCard.test.ts -
Step 7.1: Write
web/src/lib/components/PlaylistCard.svelte
<script lang="ts">
import { goto } from '$app/navigation';
import type { Playlist } from '$lib/api/types';
import { user } from '$lib/auth/store.svelte';
let { playlist }: { playlist: Playlist } = $props();
const isOwn = $derived(user.value?.id === playlist.user_id);
</script>
<button
type="button"
onclick={() => goto(`/playlists/${playlist.id}`)}
class="group flex w-full flex-col items-start gap-2 rounded-md p-2 text-left hover:bg-surface-hover"
>
<div class="aspect-square w-full overflow-hidden rounded-md bg-surface-secondary">
{#if playlist.cover_url}
<img
src={playlist.cover_url}
alt=""
class="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
{:else}
<div class="flex h-full w-full items-center justify-center text-text-muted">
<span class="text-xs">No tracks yet</span>
</div>
{/if}
</div>
<div class="w-full min-w-0">
<div class="truncate text-sm font-medium text-text-primary">{playlist.name}</div>
<div class="truncate text-xs text-text-muted">
{playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
{#if !isOwn}
· by {playlist.owner_username}
{/if}
</div>
</div>
</button>
If the FabledSword class names differ (e.g., bg-surface-secondary vs. bg-slate), adapt to whatever AlbumCard uses. Read web/src/lib/components/AlbumCard.svelte for the canonical card pattern + class names.
- Step 7.2: Write
web/src/lib/components/PlaylistCard.test.ts
import { describe, expect, test } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import { writable } from 'svelte/store';
import { vi } from 'vitest';
import PlaylistCard from './PlaylistCard.svelte';
import type { Playlist } from '$lib/api/types';
vi.mock('$lib/auth/store.svelte', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
const base: Playlist = {
id: 'p-1',
user_id: 'u-self',
owner_username: 'me',
name: 'Saturday morning',
description: '',
is_public: false,
cover_url: '',
track_count: 12,
duration_sec: 0,
created_at: '',
updated_at: ''
};
describe('PlaylistCard', () => {
test('renders own playlist without owner attribution', () => {
render(PlaylistCard, { props: { playlist: base } });
expect(screen.getByText('Saturday morning')).toBeInTheDocument();
expect(screen.getByText('12 tracks')).toBeInTheDocument();
// No "by ..." for own playlists.
expect(screen.queryByText(/by /)).not.toBeInTheDocument();
});
test('renders cover image when cover_url is set', () => {
render(PlaylistCard, { props: { playlist: { ...base, cover_url: '/api/playlists/p-1/cover' } } });
const img = screen.getByRole('img');
expect(img.getAttribute('src')).toBe('/api/playlists/p-1/cover');
});
test('renders glyph fallback when cover_url is empty', () => {
render(PlaylistCard, { props: { playlist: base } });
expect(screen.getByText(/no tracks yet/i)).toBeInTheDocument();
});
test("attributes other users' playlists to the owner", () => {
render(PlaylistCard, { props: { playlist: { ...base, user_id: 'u-bob', owner_username: 'bob' } } });
expect(screen.getByText(/by bob/i)).toBeInTheDocument();
});
});
- Step 7.3: Commit
git add web/src/lib/components/PlaylistCard.svelte web/src/lib/components/PlaylistCard.test.ts
git commit -F - <<'EOF'
feat(web): PlaylistCard component for M7 #352 slice 1
Square card with cover (or "No tracks yet" glyph fallback), name,
track count, and owner attribution when the playlist isn't the
current user's. Click navigates to /playlists/{id}.
EOF
Task 8 — <PlaylistTrackRow> component
Files:
-
Create:
web/src/lib/components/PlaylistTrackRow.svelte -
Create:
web/src/lib/components/PlaylistTrackRow.test.ts -
Step 8.1: Write
web/src/lib/components/PlaylistTrackRow.svelte
<script lang="ts">
import { GripVertical, X } from 'lucide-svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import type { PlaylistTrack } from '$lib/api/types';
import type { TrackRef } from '$lib/api/types';
let {
row,
isOwner,
onRemove,
onPlay,
onDragStart,
onDragOver,
onDrop
}: {
row: PlaylistTrack;
isOwner: boolean;
onRemove: (position: number) => void;
onPlay: (position: number) => void;
onDragStart?: (position: number) => void;
onDragOver?: (e: DragEvent, position: number) => void;
onDrop?: (e: DragEvent, position: number) => void;
} = $props();
const isUnavailable = $derived(row.track_id === null);
// Construct a minimal TrackRef for the kebab menu when the upstream
// track still exists. When unavailable, the menu is hidden.
const liveTrack = $derived(
row.track_id
? {
id: row.track_id,
title: row.title,
album_id: row.album_id ?? '',
album_title: row.album_title,
artist_id: row.artist_id ?? '',
artist_name: row.artist_name,
duration_sec: row.duration_sec,
stream_url: row.stream_url ?? ''
} as TrackRef
: null
);
function format(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
</script>
<div
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}"
draggable={isOwner && !isUnavailable}
ondragstart={() => onDragStart?.(row.position)}
ondragover={(e) => { e.preventDefault(); onDragOver?.(e, row.position); }}
ondrop={(e) => { e.preventDefault(); onDrop?.(e, row.position); }}
>
{#if isOwner}
<span
class="flex-shrink-0 cursor-grab text-text-muted active:cursor-grabbing"
aria-label="Drag handle"
>
<GripVertical size={14} strokeWidth={1} />
</span>
{/if}
<button
type="button"
class="min-w-0 flex-1 text-left"
onclick={() => !isUnavailable && onPlay(row.position)}
disabled={isUnavailable}
>
<div class="truncate {isUnavailable ? 'line-through' : ''}">{row.title}</div>
<div class="truncate text-xs text-text-muted">
{row.artist_name} · {row.album_title}
</div>
</button>
<span class="flex-shrink-0 text-xs text-text-muted">{format(row.duration_sec)}</span>
{#if !isUnavailable && liveTrack}
<LikeButton entityType="track" entityId={liveTrack.id} />
<TrackMenu track={liveTrack} />
{/if}
{#if isOwner}
<button
type="button"
onclick={() => onRemove(row.position)}
aria-label={`Remove ${row.title} from playlist`}
class="flex-shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
>
<X size={14} strokeWidth={1} />
</button>
{/if}
</div>
If the project's LikeButton API differs (e.g., uses kind instead of entityType), adapt. Read web/src/lib/components/LikeButton.svelte for the canonical shape — Task 16 of M7 #356 documented this surface.
- Step 8.2: Write
web/src/lib/components/PlaylistTrackRow.test.ts
import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import PlaylistTrackRow from './PlaylistTrackRow.svelte';
import type { PlaylistTrack } from '$lib/api/types';
// LikeButton + TrackMenu transitively pull in TanStack Query and the
// auth store; mock both at module level.
vi.mock('@tanstack/svelte-query', () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
createQuery: () => ({ subscribe: () => () => {} })
}));
vi.mock('$lib/auth/store.svelte', () => ({
user: { value: { id: 'u', username: 'me', is_admin: false } }
}));
vi.mock('$lib/api/likes', () => ({
createLikedIdsQuery: () => ({
subscribe: () => () => {},
data: { track_ids: [], album_ids: [], artist_ids: [] }
}),
likeEntity: vi.fn(),
unlikeEntity: vi.fn()
}));
vi.mock('$lib/api/quarantine', () => ({
createMyQuarantineQuery: () => ({ subscribe: () => () => {} }),
flagTrack: vi.fn(),
unflagTrack: vi.fn()
}));
vi.mock('$lib/player/store.svelte', () => ({
playNext: vi.fn(),
enqueueTrack: vi.fn()
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
const live: PlaylistTrack = {
position: 2,
track_id: 't-1',
album_id: 'a-1',
artist_id: 'ar-1',
title: 'Roygbiv',
artist_name: 'Boards of Canada',
album_title: 'MHTRTC',
duration_sec: 137,
stream_url: '/api/tracks/t-1/stream',
added_at: ''
};
const removed: PlaylistTrack = {
...live,
track_id: null,
album_id: null,
artist_id: null,
stream_url: null
};
describe('PlaylistTrackRow', () => {
test('renders title, artist, mm:ss duration', () => {
render(PlaylistTrackRow, {
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
});
expect(screen.getByText('Roygbiv')).toBeInTheDocument();
expect(screen.getByText('2:17')).toBeInTheDocument();
});
test('shows drag handle and remove button for owner', () => {
render(PlaylistTrackRow, {
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
});
expect(screen.getByLabelText('Drag handle')).toBeInTheDocument();
expect(screen.getByLabelText(/Remove Roygbiv from playlist/i)).toBeInTheDocument();
});
test('hides drag handle and remove button for non-owner', () => {
render(PlaylistTrackRow, {
props: { row: live, isOwner: false, onRemove: vi.fn(), onPlay: vi.fn() }
});
expect(screen.queryByLabelText('Drag handle')).not.toBeInTheDocument();
expect(screen.queryByLabelText(/Remove/i)).not.toBeInTheDocument();
});
test('greyed-out + strikethrough when track_id is null', () => {
render(PlaylistTrackRow, {
props: { row: removed, isOwner: true, onRemove: vi.fn(), onPlay: vi.fn() }
});
const titleEl = screen.getByText('Roygbiv');
expect(titleEl.className).toContain('line-through');
});
test('clicking the title fires onPlay with the position', async () => {
const onPlay = vi.fn();
render(PlaylistTrackRow, {
props: { row: live, isOwner: true, onRemove: vi.fn(), onPlay }
});
await fireEvent.click(screen.getByText('Roygbiv'));
expect(onPlay).toHaveBeenCalledWith(2);
});
});
- Step 8.3: Commit
git add web/src/lib/components/PlaylistTrackRow.svelte web/src/lib/components/PlaylistTrackRow.test.ts
git commit -F - <<'EOF'
feat(web): PlaylistTrackRow component for M7 #352 slice 1
Variant of TrackRow specialised for playlist detail. Drag handle
visible to owner only; remove button (X) visible to owner only;
greyed-out + strikethrough when track_id is null (upstream track
removed from library). Reuses the existing LikeButton and TrackMenu
when the track is still alive.
EOF
Task 9 — <AddToPlaylistMenu> + wire into <TrackMenu>
Files:
-
Create:
web/src/lib/components/AddToPlaylistMenu.svelte -
Create:
web/src/lib/components/AddToPlaylistMenu.test.ts -
Modify:
web/src/lib/components/TrackMenu.svelte— replace the disabled "Add to playlist…" entry with a real opener -
Modify:
web/src/lib/components/TrackMenu.test.ts— update the assertion -
Step 9.1: Write
web/src/lib/components/AddToPlaylistMenu.svelte
<script lang="ts">
import { Plus } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import { user } from '$lib/auth/store.svelte';
import { createPlaylistsQuery, appendTracks, createPlaylist } from '$lib/api/playlists';
import { qk } from '$lib/api/queries';
import { copyForCode } from '$lib/api/error-copy';
import TrackMenuItem from './TrackMenuItem.svelte';
import TrackMenuDivider from './TrackMenuDivider.svelte';
import type { TrackRef } from '$lib/api/types';
let {
track,
onClose
}: {
track: TrackRef;
onClose: () => void;
} = $props();
const queryClient = useQueryClient();
const playlistsQ = $derived(createPlaylistsQuery());
const ownPlaylists = $derived(
($playlistsQ?.data?.owned ?? []).slice().sort((a, b) => a.name.localeCompare(b.name))
);
let creating = $state(false);
let newName = $state('');
let busy = $state(false);
let error = $state<string | null>(null);
async function add(playlistID: string) {
busy = true;
error = null;
try {
await appendTracks(playlistID, [track.id]);
await queryClient.invalidateQueries({ queryKey: qk.playlist(playlistID) });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
onClose();
} catch (e: unknown) {
const code = (e as { code?: string })?.code ?? 'unknown';
error = copyForCode(code);
} finally {
busy = false;
}
}
async function createAndAdd() {
if (!newName.trim()) {
error = 'Name is required';
return;
}
busy = true;
error = null;
try {
const created = await createPlaylist({ name: newName.trim() });
await appendTracks(created.id, [track.id]);
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
onClose();
} catch (e: unknown) {
const code = (e as { code?: string })?.code ?? 'unknown';
error = copyForCode(code);
} finally {
busy = false;
}
}
</script>
<div
role="menu"
aria-label="Add to playlist"
class="absolute right-full top-0 mr-1 w-56 rounded-md border border-border bg-surface p-1 shadow-lg"
onclick={(e) => e.stopPropagation()}
>
{#each ownPlaylists as p (p.id)}
<TrackMenuItem
icon={Plus}
label={p.name}
onclick={() => add(p.id)}
/>
{/each}
{#if ownPlaylists.length > 0}
<TrackMenuDivider />
{/if}
{#if !creating}
<TrackMenuItem
icon={Plus}
label="New playlist…"
onclick={() => { creating = true; }}
/>
{:else}
<div class="p-2">
<input
type="text"
placeholder="Playlist name"
bind:value={newName}
class="w-full rounded border border-border bg-background px-2 py-1 text-sm text-text-primary"
onkeydown={(e) => { if (e.key === 'Enter') createAndAdd(); }}
/>
<div class="mt-2 flex justify-end gap-1">
<button
type="button"
onclick={() => { creating = false; newName = ''; }}
class="rounded px-2 py-1 text-xs text-text-muted hover:bg-surface-hover"
>
Cancel
</button>
<button
type="button"
onclick={createAndAdd}
disabled={busy || !newName.trim()}
class="rounded bg-action-secondary px-2 py-1 text-xs text-text-primary disabled:opacity-50"
>
Create & add
</button>
</div>
</div>
{/if}
{#if error}
<p class="px-2 py-1 text-xs text-action-destructive">{error}</p>
{/if}
</div>
- Step 9.2: Write
web/src/lib/components/AddToPlaylistMenu.test.ts
import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
import type { TrackRef } from '$lib/api/types';
vi.mock('@tanstack/svelte-query', () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) })
}));
vi.mock('$lib/auth/store.svelte', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
}));
const playlistsData = {
owned: [
{ id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'B-list', description: '', is_public: false, cover_url: '', track_count: 3, duration_sec: 0, created_at: '', updated_at: '' },
{ id: 'p2', user_id: 'u-self', owner_username: 'me', name: 'A-list', description: '', is_public: false, cover_url: '', track_count: 5, duration_sec: 0, created_at: '', updated_at: '' }
],
public: []
};
vi.mock('$lib/api/playlists', () => ({
createPlaylistsQuery: () => ({
subscribe: (cb: (v: unknown) => void) => { cb({ data: playlistsData }); return () => {}; },
// The component uses `$derived(createPlaylistsQuery())` which expects
// a store-like object. Provide a `data` property directly so the
// sort-by-name fallback path can read it without a real store.
data: playlistsData
}),
appendTracks: vi.fn().mockResolvedValue({ id: 'p1', tracks: [] }),
createPlaylist: vi.fn().mockResolvedValue({ id: 'p3', name: 'New' })
}));
const track: TrackRef = {
id: 't1', title: 'Roygbiv',
album_id: 'a1', album_title: 'MHTRTC',
artist_id: 'ar1', artist_name: 'BoC',
duration_sec: 137, stream_url: '/api/tracks/t1/stream'
} as unknown as TrackRef;
describe('AddToPlaylistMenu', () => {
test('lists own playlists alphabetically', () => {
render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } });
const items = screen.getAllByRole('menuitem');
// First two should be the playlists in alpha order, then "New playlist…".
expect(items[0].textContent).toContain('A-list');
expect(items[1].textContent).toContain('B-list');
expect(items[2].textContent).toContain('New playlist');
});
test('clicking a playlist appends and closes', async () => {
const onClose = vi.fn();
const { appendTracks } = await import('$lib/api/playlists');
render(AddToPlaylistMenu, { props: { track, onClose } });
await fireEvent.click(screen.getByRole('menuitem', { name: /A-list/ }));
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(appendTracks).toHaveBeenCalledWith('p2', ['t1']);
});
test('"New playlist…" toggles inline create form', async () => {
render(AddToPlaylistMenu, { props: { track, onClose: vi.fn() } });
await fireEvent.click(screen.getByRole('menuitem', { name: /New playlist/ }));
expect(screen.getByPlaceholderText(/playlist name/i)).toBeInTheDocument();
expect(screen.getByText(/Create & add/i)).toBeInTheDocument();
});
});
The mocked createPlaylistsQuery shape is approximate. If the component reads via $derived($store?.data), the mock needs to produce an object with a subscribe method that calls back with a value containing data. Read LikeButton.svelte and its test for the project's mocking pattern of TanStack Query stores in Svelte 5.
- Step 9.3: Wire into
TrackMenu.svelte
Read web/src/lib/components/TrackMenu.svelte. Find the disabled "Add to playlist…" entry:
<TrackMenuItem
icon={ListMusic}
label="Add to playlist…"
disabled
title="Coming with playlists"
/>
Replace with an opener that mounts <AddToPlaylistMenu>:
<TrackMenuItem
icon={ListMusic}
label="Add to playlist…"
onclick={() => { addOpen = true; menuOpen = false; }}
/>
Add let addOpen = $state(false); near the other state declarations. Add the popover render block alongside the existing RemoveTrackPopover block:
{#if addOpen}
<AddToPlaylistMenu {track} onClose={() => (addOpen = false)} />
{/if}
Add the import at the top:
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
- Step 9.4: Update
TrackMenu.test.ts
Find the assertion that verifies "Add to playlist…" is disabled:
test('"Add to playlist…" is disabled with a tooltip until #352 ships', ...)
Replace with:
test('"Add to playlist…" is enabled and opens the AddToPlaylistMenu submenu', async () => {
render(TrackMenu, { props: { track } });
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
const item = screen.getByRole('menuitem', { name: /add to playlist/i });
expect(item.getAttribute('aria-disabled')).toBe('false');
await fireEvent.click(item);
await waitFor(() =>
expect(screen.getByRole('menu', { name: /add to playlist/i })).toBeInTheDocument()
);
});
Add vi.mock('$lib/api/playlists', ...) and vi.mock('$lib/api/queries', ...) mocks at the top of the file if AddToPlaylistMenu's imports cascade into the test. Read the existing file to see the existing mock block and extend it.
- Step 9.5: Commit
git add web/src/lib/components/AddToPlaylistMenu.svelte web/src/lib/components/AddToPlaylistMenu.test.ts web/src/lib/components/TrackMenu.svelte web/src/lib/components/TrackMenu.test.ts
git commit -F - <<'EOF'
feat(web): AddToPlaylistMenu submenu wired into TrackMenu (M7 #352)
The "Add to playlist…" entry that #372 reserved as a disabled slot
is now active. Submenu lists the operator's own playlists
alphabetically; "New playlist…" toggles an inline create form that
makes the playlist + appends the track in two API calls.
TrackMenu's existing test asserts the entry is enabled and opens the
submenu instead of the previous "is disabled with tooltip" check.
EOF
Task 10 — /playlists index page
Files:
-
Modify (rewrite):
web/src/routes/playlists/+page.svelte— replaces the placeholder -
Create:
web/src/routes/playlists/playlists.test.ts -
Step 10.1: Rewrite
web/src/routes/playlists/+page.svelte
<script lang="ts">
import { goto } from '$app/navigation';
import { Plus } from 'lucide-svelte';
import PlaylistCard from '$lib/components/PlaylistCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
import { useQueryClient } from '@tanstack/svelte-query';
import { qk } from '$lib/api/queries';
const playlistsQ = $derived(createPlaylistsQuery());
const queryClient = useQueryClient();
let creating = $state(false);
let newName = $state('');
let creatingBusy = $state(false);
let createError = $state<string | null>(null);
async function submitCreate() {
if (!newName.trim()) {
createError = 'Name is required';
return;
}
creatingBusy = true;
createError = null;
try {
const p = await createPlaylist({ name: newName.trim() });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
goto(`/playlists/${p.id}`);
} catch (e: unknown) {
createError = (e as { message?: string })?.message ?? 'Could not create.';
} finally {
creatingBusy = false;
creating = false;
newName = '';
}
}
</script>
<div class="mx-auto max-w-6xl px-4 py-6">
<header class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-medium text-text-primary">Playlists</h1>
<button
type="button"
onclick={() => (creating = true)}
class="inline-flex items-center gap-1 rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary hover:bg-action-secondary-hover"
>
<Plus size={14} strokeWidth={1} />
New playlist
</button>
</header>
{#if creating}
<div class="mb-6 rounded-md border border-border bg-surface p-4">
<label class="block text-sm font-medium text-text-primary">
Name
<input
type="text"
bind:value={newName}
placeholder="Saturday morning"
autofocus
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
onkeydown={(e) => { if (e.key === 'Enter') submitCreate(); if (e.key === 'Escape') creating = false; }}
/>
</label>
{#if createError}
<p class="mt-2 text-xs text-action-destructive">{createError}</p>
{/if}
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
onclick={() => { creating = false; newName = ''; createError = null; }}
>
Cancel
</button>
<button
type="button"
onclick={submitCreate}
disabled={creatingBusy || !newName.trim()}
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary disabled:opacity-50"
>
Create
</button>
</div>
</div>
{/if}
{#if $playlistsQ?.isPending}
<p class="text-text-muted">Loading playlists…</p>
{:else if $playlistsQ?.isError}
<ApiErrorBanner error={$playlistsQ.error} onRetry={() => $playlistsQ.refetch()} />
{:else if $playlistsQ?.data}
<section class="mb-8">
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">Your playlists</h2>
{#if $playlistsQ.data.owned.length === 0}
<p class="text-sm text-text-muted">No playlists yet. Click "New playlist" to start one.</p>
{:else}
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each $playlistsQ.data.owned as p (p.id)}
<PlaylistCard playlist={p} />
{/each}
</div>
{/if}
</section>
{#if $playlistsQ.data.public.length > 0}
<section>
<h2 class="mb-3 text-sm font-medium uppercase tracking-wide text-text-muted">From other users</h2>
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each $playlistsQ.data.public as p (p.id)}
<PlaylistCard playlist={p} />
{/each}
</div>
</section>
{/if}
{/if}
</div>
- Step 10.2: Write
web/src/routes/playlists/playlists.test.ts
import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../test-utils/query';
import type { Playlist } from '$lib/api/types';
vi.mock('$lib/api/playlists', () => ({
createPlaylistsQuery: vi.fn(),
createPlaylist: vi.fn().mockResolvedValue({ id: 'p-new', name: 'X' })
}));
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) })
};
});
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
import PlaylistsPage from './+page.svelte';
import { createPlaylistsQuery, createPlaylist } from '$lib/api/playlists';
const mockedCreate = createPlaylistsQuery as ReturnType<typeof vi.fn>;
const mockedCreatePlaylist = createPlaylist as ReturnType<typeof vi.fn>;
function p(over: Partial<Playlist>): Playlist {
return {
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'A',
description: '', is_public: false, cover_url: '', track_count: 0, duration_sec: 0,
created_at: '', updated_at: '', ...over
};
}
describe('Playlists index page', () => {
test('renders own + public sections', () => {
mockedCreate.mockReturnValue(mockQuery({
data: {
owned: [p({ id: 'p1', name: 'Mine' })],
public: [p({ id: 'p2', name: 'Theirs', user_id: 'u-other', owner_username: 'bob' })]
}
}));
render(PlaylistsPage);
expect(screen.getByText('Mine')).toBeInTheDocument();
expect(screen.getByText('Theirs')).toBeInTheDocument();
expect(screen.getByText(/your playlists/i)).toBeInTheDocument();
expect(screen.getByText(/from other users/i)).toBeInTheDocument();
});
test('empty-state copy when operator has no playlists', () => {
mockedCreate.mockReturnValue(mockQuery({
data: { owned: [], public: [] }
}));
render(PlaylistsPage);
expect(screen.getByText(/no playlists yet/i)).toBeInTheDocument();
});
test('Create button reveals inline form; submit creates and navigates', async () => {
mockedCreate.mockReturnValue(mockQuery({ data: { owned: [], public: [] } }));
render(PlaylistsPage);
await fireEvent.click(screen.getByRole('button', { name: /new playlist/i }));
const input = screen.getByPlaceholderText(/saturday morning/i);
await fireEvent.input(input, { target: { value: 'Test' } });
await fireEvent.click(screen.getByRole('button', { name: /^create$/i }));
await waitFor(() => expect(mockedCreatePlaylist).toHaveBeenCalledWith({ name: 'Test' }));
});
});
The mockQuery helper exists at web/src/test-utils/query.ts (same one used elsewhere). Read it first to confirm the import path.
- Step 10.3: Commit
git add web/src/routes/playlists/+page.svelte web/src/routes/playlists/playlists.test.ts
git commit -F - <<'EOF'
feat(web): /playlists index page (M7 #352 slice 1)
Replaces the placeholder route. Two sections: "Your playlists" (owned)
and "From other users" (public). Inline create form in the header
with Enter-to-submit / Esc-to-cancel. Empty-state copy when the
operator has nothing yet. Routes to /playlists/{id} on card click via
PlaylistCard.
EOF
Task 11 — /playlists/{id} detail page with drag-reorder
Files:
-
Create:
web/src/routes/playlists/[id]/+page.svelte -
Create:
web/src/routes/playlists/[id]/playlist.test.ts -
Step 11.1: Write
web/src/routes/playlists/[id]/+page.svelte
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { Pencil, Trash2 } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import PlaylistTrackRow from '$lib/components/PlaylistTrackRow.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import {
createPlaylistQuery,
updatePlaylist,
deletePlaylist,
removePlaylistTrack,
reorderPlaylist
} from '$lib/api/playlists';
import { qk } from '$lib/api/queries';
import { user } from '$lib/auth/store.svelte';
import { copyForCode } from '$lib/api/error-copy';
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
import type { TrackRef } from '$lib/api/types';
const id = $derived($page.params.id);
const queryClient = useQueryClient();
const playlistQ = $derived(createPlaylistQuery(id));
const isOwner = $derived(user.value?.id === $playlistQ?.data?.user_id);
// Local optimistic ordering for drag-reorder. Falls back to the
// server's order when no drag is in progress.
let dragFromPos = $state<number | null>(null);
async function onDrop(toPos: number) {
if (dragFromPos === null || !$playlistQ?.data) return;
if (dragFromPos === toPos) {
dragFromPos = null;
return;
}
const cur = $playlistQ.data.tracks.map((t) => t.position);
const moved = cur.splice(dragFromPos, 1)[0];
cur.splice(toPos, 0, moved);
dragFromPos = null;
try {
await reorderPlaylist(id, cur);
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
} catch (e: unknown) {
const code = (e as { code?: string })?.code ?? 'unknown';
alert(copyForCode(code)); // simple fallback; toast surface is a polish item
}
}
async function onRemove(position: number) {
try {
await removePlaylistTrack(id, position);
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
} catch (e: unknown) {
const code = (e as { code?: string })?.code ?? 'unknown';
alert(copyForCode(code));
}
}
function onPlay(position: number) {
if (!$playlistQ?.data) return;
const live = $playlistQ.data.tracks
.filter((t) => t.track_id !== null)
.map(toTrackRef);
const startIdx = live.findIndex((_, i) => $playlistQ.data!.tracks[i].position === position);
playQueue(live, Math.max(0, startIdx));
}
function toTrackRef(t: { track_id: string | null; album_id: string | null; artist_id: string | null; title: string; album_title: string; artist_name: string; duration_sec: number; stream_url: string | null }): TrackRef {
return {
id: t.track_id!,
title: t.title,
album_id: t.album_id ?? '',
album_title: t.album_title,
artist_id: t.artist_id ?? '',
artist_name: t.artist_name,
duration_sec: t.duration_sec,
stream_url: t.stream_url ?? ''
} as unknown as TrackRef;
}
let editing = $state(false);
let editName = $state('');
let editDesc = $state('');
let editIsPublic = $state(false);
function startEdit() {
if (!$playlistQ?.data) return;
editName = $playlistQ.data.name;
editDesc = $playlistQ.data.description;
editIsPublic = $playlistQ.data.is_public;
editing = true;
}
async function submitEdit() {
try {
await updatePlaylist(id, { name: editName, description: editDesc, is_public: editIsPublic });
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
editing = false;
} catch (e: unknown) {
const code = (e as { code?: string })?.code ?? 'unknown';
alert(copyForCode(code));
}
}
async function submitDelete() {
if (!confirm('Delete this playlist? This cannot be undone.')) return;
try {
await deletePlaylist(id);
await queryClient.invalidateQueries({ queryKey: qk.playlists() });
goto('/playlists');
} catch (e: unknown) {
const code = (e as { code?: string })?.code ?? 'unknown';
alert(copyForCode(code));
}
}
</script>
<div class="mx-auto max-w-4xl px-4 py-6">
{#if $playlistQ?.isPending}
<p class="text-text-muted">Loading…</p>
{:else if $playlistQ?.isError}
<ApiErrorBanner error={$playlistQ.error} onRetry={() => $playlistQ.refetch()} />
{:else if $playlistQ?.data}
{@const pl = $playlistQ.data}
{#if !editing}
<header class="mb-6 flex items-start gap-4">
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-secondary">
{#if pl.cover_url}
<img src={pl.cover_url} alt="" class="h-full w-full object-cover" />
{/if}
</div>
<div class="min-w-0 flex-1">
<h1 class="truncate text-2xl font-medium text-text-primary">{pl.name}</h1>
{#if pl.description}
<p class="mt-1 text-sm text-text-muted">{pl.description}</p>
{/if}
<p class="mt-2 text-xs text-text-muted">
{pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'}
{#if pl.is_public}· public{:else}· private{/if}
{#if !isOwner}· by {pl.owner_username}{/if}
</p>
</div>
{#if isOwner}
<button
type="button"
onclick={startEdit}
aria-label="Edit playlist"
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-text-primary"
>
<Pencil size={16} strokeWidth={1} />
</button>
<button
type="button"
onclick={submitDelete}
aria-label="Delete playlist"
class="rounded p-2 text-text-muted hover:bg-surface-hover hover:text-action-destructive"
>
<Trash2 size={16} strokeWidth={1} />
</button>
{/if}
</header>
{:else}
<section class="mb-6 rounded-md border border-border bg-surface p-4">
<label class="block text-sm font-medium text-text-primary">
Name
<input
type="text"
bind:value={editName}
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
/>
</label>
<label class="mt-3 block text-sm font-medium text-text-primary">
Description
<textarea
bind:value={editDesc}
rows="2"
class="mt-1 w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-text-primary"
></textarea>
</label>
<label class="mt-3 flex items-center gap-2 text-sm text-text-primary">
<input type="checkbox" bind:checked={editIsPublic} class="rounded border-border" />
Make this playlist public
</label>
<div class="mt-3 flex justify-end gap-2">
<button
type="button"
onclick={() => (editing = false)}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-muted hover:text-text-primary"
>
Cancel
</button>
<button
type="button"
onclick={submitEdit}
class="rounded-md bg-action-secondary px-3 py-1.5 text-sm text-text-primary"
>
Save
</button>
</div>
</section>
{/if}
<section class="rounded-md border border-border bg-surface">
{#if pl.tracks.length === 0}
<p class="px-3 py-6 text-sm text-text-muted">
{#if isOwner}
No tracks yet. Add some via the "Add to playlist…" entry on any track row.
{:else}
No tracks in this playlist.
{/if}
</p>
{:else}
{#each pl.tracks as row (row.position)}
<PlaylistTrackRow
{row}
isOwner={isOwner ?? false}
onRemove={onRemove}
onPlay={onPlay}
onDragStart={(p) => (dragFromPos = p)}
onDrop={(_, p) => onDrop(p)}
/>
{/each}
{/if}
</section>
{/if}
</div>
enqueueTracks and playQueue are existing player-store exports per Task 5 of M7 #356. Read web/src/lib/player/store.svelte.ts for the exact signatures and adapt — the snippet uses playQueue(tracks, startIndex). If the actual signature differs, adjust.
The alert(...) toast fallback is intentional: a real toast surface is a separate polish task; in slice 1, a browser alert is loud-but-functional and won't be missed in dev. CI tests won't exercise the alert path because they intercept via mocked fetch.
- Step 11.2: Write
web/src/routes/playlists/[id]/playlist.test.ts
import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockQuery } from '../../test-utils/query';
import { writable } from 'svelte/store';
import type { PlaylistDetail } from '$lib/api/types';
vi.mock('$app/stores', () => ({
page: writable({ params: { id: 'p1' } })
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$lib/api/playlists', () => ({
createPlaylistQuery: vi.fn(),
updatePlaylist: vi.fn().mockResolvedValue(undefined),
deletePlaylist: vi.fn().mockResolvedValue(undefined),
removePlaylistTrack: vi.fn().mockResolvedValue(undefined),
reorderPlaylist: vi.fn().mockResolvedValue(undefined)
}));
vi.mock('@tanstack/svelte-query', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn().mockResolvedValue(undefined) }) };
});
vi.mock('$lib/auth/store.svelte', () => ({
user: { value: { id: 'u-self', username: 'me', is_admin: false } }
}));
vi.mock('$lib/player/store.svelte', () => ({
playQueue: vi.fn(),
enqueueTracks: vi.fn(),
playNext: vi.fn(),
enqueueTrack: vi.fn()
}));
// Cascade through PlaylistTrackRow's mocks.
vi.mock('$lib/api/likes', () => ({
createLikedIdsQuery: () => ({ subscribe: () => () => {}, data: { track_ids: [], album_ids: [], artist_ids: [] } }),
likeEntity: vi.fn(),
unlikeEntity: vi.fn()
}));
vi.mock('$lib/api/quarantine', () => ({
createMyQuarantineQuery: () => ({ subscribe: () => () => {}, data: [] }),
flagTrack: vi.fn(),
unflagTrack: vi.fn()
}));
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
import PlaylistDetailPage from './+page.svelte';
import { createPlaylistQuery, deletePlaylist, reorderPlaylist } from '$lib/api/playlists';
const mockedQuery = createPlaylistQuery as ReturnType<typeof vi.fn>;
const ownDetail: PlaylistDetail = {
id: 'p1', user_id: 'u-self', owner_username: 'me', name: 'Mine',
description: '', is_public: false, cover_url: '', track_count: 2, duration_sec: 274,
created_at: '', updated_at: '',
tracks: [
{ position: 0, track_id: 't1', album_id: 'a1', artist_id: 'ar1', title: 'A', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t1/stream', added_at: '' },
{ position: 1, track_id: 't2', album_id: 'a1', artist_id: 'ar1', title: 'B', artist_name: 'X', album_title: 'Y', duration_sec: 137, stream_url: '/api/tracks/t2/stream', added_at: '' }
]
};
describe('Playlist detail page', () => {
test('renders header + tracks for owner', () => {
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
render(PlaylistDetailPage);
expect(screen.getByText('Mine')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.getByText('B')).toBeInTheDocument();
expect(screen.getByLabelText(/edit playlist/i)).toBeInTheDocument();
expect(screen.getByLabelText(/delete playlist/i)).toBeInTheDocument();
});
test('hides edit + delete for non-owner', () => {
mockedQuery.mockReturnValue(mockQuery({
data: { ...ownDetail, user_id: 'u-other', owner_username: 'bob', is_public: true }
}));
render(PlaylistDetailPage);
expect(screen.queryByLabelText(/edit playlist/i)).not.toBeInTheDocument();
expect(screen.queryByLabelText(/delete playlist/i)).not.toBeInTheDocument();
});
test('Delete button confirms then deletes', async () => {
mockedQuery.mockReturnValue(mockQuery({ data: ownDetail }));
const confirmSpy = vi.spyOn(globalThis, 'confirm').mockReturnValue(true);
render(PlaylistDetailPage);
await fireEvent.click(screen.getByLabelText(/delete playlist/i));
await waitFor(() => expect(deletePlaylist).toHaveBeenCalledWith('p1'));
confirmSpy.mockRestore();
});
});
- Step 11.3: Commit
git add web/src/routes/playlists/[id]/+page.svelte web/src/routes/playlists/[id]/playlist.test.ts
git commit -F - <<'EOF'
feat(web): /playlists/{id} detail page with drag-reorder (M7 #352)
Header shows collage, name, description, public/private chip, track
count, owner attribution (when not owner). Edit + delete buttons
visible to the owner only. Track list uses PlaylistTrackRow with
HTML5 drag-and-drop; drop fires PUT /tracks with the new ordered
positions and TanStack Query invalidates the playlist + index cache.
Toast surface is a placeholder browser alert in slice 1 — a real
toast is a polish task whenever it lands.
EOF
Self-review
-
Spec coverage:
- §2 Goals — all goals point at tasks: schema (T1), service (T2 + T3), collage (T4), API (T5), api helper + types + queries (T6), PlaylistCard (T7), PlaylistTrackRow (T8), AddToPlaylistMenu + TrackMenu wiring (T9),
/playlists(T10),/playlists/{id}(T11). ✓ - §3 Architecture — schema, service, API, frontend pages all covered.
- §3.5 Drag-to-reorder — HTML5 native, optimistic update, T11 wires it. ✓
- §3.6 "Add to playlist" flow — T9 covers submenu + inline new-playlist creation. ✓
- §3.7 Permissions matrix — service-layer enforces ErrForbidden in T2/T3, API layer maps to 403 not_authorized in T5. ✓
- §5 API contract — every endpoint has a handler in T5 with matching response shape. ✓
- §6 Error handling —
copyForCodeinvocations land in AddToPlaylistMenu (T9) and the detail page (T11). Thealert()fallback is documented as a slice-1 trade-off. - §7 Testing — every test file from §7 has a writing step.
- §8 Distribution — migration 0014 lives in T1; data dir creation is service-side in T4 (
os.MkdirAll).
- §2 Goals — all goals point at tasks: schema (T1), service (T2 + T3), collage (T4), API (T5), api helper + types + queries (T6), PlaylistCard (T7), PlaylistTrackRow (T8), AddToPlaylistMenu + TrackMenu wiring (T9),
-
Placeholder scan: No "TBD"/"add validation"/"handle edge cases" in any task. The alert() toast is documented intentional. The fallback glyph is a documented intentional slice-1 placeholder.
-
Type consistency:
- Backend service signatures:
Service.AppendTracks(ctx, callerID, playlistID, []pgtype.UUID) errorand friends — consistent across T2 / T3 / T5. PlaylistRow,PlaylistDetail,PlaylistTrackGo types match between service.go and the API view structs in T5.- TS types:
Playlist,PlaylistDetail,PlaylistTrack— defined in T6, used everywhere downstream. - Query keys:
qk.playlists(),qk.playlist(id)— defined in T6, used in T9, T10, T11. - API URLs: every helper in T6 matches a handler route registered in T5.
- Backend service signatures:
One spec-deviation worth noting:
- The spec's §3.2 says "Triggers cover regeneration if the first 4 positions changed" for Reorder. The plan's T3 implementation triggers regeneration unconditionally on any reorder. Reasoning baked in: "cheap, and reasoning about which moves matter is more error-prone than just doing the work." Acceptable — net no behavioral difference for slice 1 (collages get regenerated; might just happen on a few extra calls).