# 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`](../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 run `npm 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; never `gh` CLI.
- `project_subsonic_legacy.md` — `/api/*` only; no `/rest/*` parity.
- `project_git_workflow.md` — commit on `dev`; PR to `main` separately.

---

## File map

### Backend — create

- `internal/db/migrations/0014_playlists.up.sql`
- `internal/db/migrations/0014_playlists.down.sql`
- `internal/db/queries/playlists.sql` — Create / Get / List / Update / Delete + tracks insert / delete / reorder
- `internal/playlists/service.go` — `Service` with `Create`, `Get`, `List`, `Update`, `Delete`, `AppendTracks`, `RemoveTrack`, `Reorder`. Typed errors `ErrNotFound`, `ErrForbidden`, `ErrInvalidInput`, `ErrTrackNotFound`.
- `internal/playlists/service_test.go`
- `internal/playlists/collage.go` — `GenerateCollage(ctx, q, playlistID, dataDir) (relPath string, err)`. 2×2 grid via `image/jpeg`; missing cells filled with `album-fallback.svg` rasterized to 300×300.
- `internal/playlists/collage_test.go`
- `internal/api/playlists.go` — 9 HTTP handlers
- `internal/api/playlists_test.go`

### Backend — modify

- `internal/db/dbq/*` — regenerated by `sqlc generate`
- `internal/api/api.go` — `Mount` gains a `playlistsSvc *playlists.Service` arg; add 9 routes inside `authed.Group(...)`
- `internal/server/server.go` — construct `playlistsSvc := playlists.NewService(...)` and pass to `api.Mount`

### Frontend — create

- `web/src/lib/api/playlists.ts` — `listPlaylists`, `getPlaylist`, `createPlaylist`, `updatePlaylist`, `deletePlaylist`, `appendTracks`, `reorderPlaylist`, `removeTrack`. Plus `createPlaylistsQuery()` and `createPlaylistQuery(id)` factories.
- `web/src/lib/api/playlists.test.ts`
- `web/src/lib/components/PlaylistCard.svelte` — collage + name + track_count + (creator name when not owned).
- `web/src/lib/components/PlaylistCard.test.ts`
- `web/src/lib/components/AddToPlaylistMenu.svelte` — submenu mounted from `<TrackMenu>`'s "Add to playlist…" entry.
- `web/src/lib/components/AddToPlaylistMenu.test.ts`
- `web/src/lib/components/PlaylistTrackRow.svelte` — variant of `<TrackRow>` for the detail page; drag handle + strikethrough for `track_id=NULL`.
- `web/src/lib/components/PlaylistTrackRow.test.ts`
- `web/src/routes/playlists/+page.svelte` (rewrite of placeholder)
- `web/src/routes/playlists/[id]/+page.svelte`
- `web/src/routes/playlists/playlists.test.ts`
- `web/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` — add `qk.playlists()`, `qk.playlist(id)`.
- `web/src/lib/api/types.ts` — add `Playlist`, `PlaylistDetail`, `PlaylistTrack` types.

---

## 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/*` via `sqlc generate`

- [ ] **Step 1.1: Write `internal/db/migrations/0014_playlists.up.sql`**

```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`**

```sql
DROP TABLE IF EXISTS playlist_tracks;
DROP TABLE IF EXISTS playlists;
```

- [ ] **Step 1.3: Write `internal/db/queries/playlists.sql`**

```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**

```bash
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**

```bash
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`**

```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)**

```go
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**

```bash
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` — add `AppendTracks`, `RemoveTrack`, `Reorder`
- Modify: `internal/playlists/service_test.go` — track-ops cases

- [ ] **Step 3.1: Append `AppendTracks`, `RemoveTrack`, `Reorder` to `internal/playlists/service.go`**

Add after the `Delete` method:

```go
// 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`**

```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`**

```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`**

```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.MkdirAll` import**

Already in step 4.1.

- [ ] **Step 4.4: Commit Task 3 + Task 4 together**

```bash
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` — `Mount` gains `playlistsSvc *playlists.Service`; register 9 routes
- Modify: `internal/server/server.go` — construct `playlistsSvc`; pass to `Mount`

- [ ] **Step 5.1: Write `internal/api/playlists.go`**

```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`**

```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:

```go
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:

```go
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**

```bash
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` — add `Playlist`, `PlaylistDetail`, `PlaylistTrack`
- Modify: `web/src/lib/api/queries.ts` — add `qk.playlists()`, `qk.playlist(id)`

- [ ] **Step 6.1: Add types to `web/src/lib/api/types.ts`**

Append (or add near the existing entity types):

```ts
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:

```ts
playlists: () => ['playlists'] as const,
playlist: (id: string) => ['playlist', id] as const,
```

- [ ] **Step 6.3: Write `web/src/lib/api/playlists.ts`**

```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`**

```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**

```bash
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`**

```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`**

```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**

```bash
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`**

```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`**

```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**

```bash
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`**

```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`**

```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:

```svelte
<TrackMenuItem
  icon={ListMusic}
  label="Add to playlist…"
  disabled
  title="Coming with playlists"
/>
```

Replace with an opener that mounts `<AddToPlaylistMenu>`:

```svelte
<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:

```svelte
{#if addOpen}
  <AddToPlaylistMenu {track} onClose={() => (addOpen = false)} />
{/if}
```

Add the import at the top:

```svelte
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
```

- [ ] **Step 9.4: Update `TrackMenu.test.ts`**

Find the assertion that verifies "Add to playlist…" is disabled:

```ts
test('"Add to playlist…" is disabled with a tooltip until #352 ships', ...)
```

Replace with:

```ts
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**

```bash
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`**

```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`**

```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**

```bash
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`**

```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`**

```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**

```bash
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

1. **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 — `copyForCode` invocations land in AddToPlaylistMenu (T9) and the detail page (T11). The `alert()` 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. **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.

3. **Type consistency:**
   - Backend service signatures: `Service.AppendTracks(ctx, callerID, playlistID, []pgtype.UUID) error` and friends — consistent across T2 / T3 / T5.
   - `PlaylistRow`, `PlaylistDetail`, `PlaylistTrack` Go 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.

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).
