diff --git a/docs/superpowers/plans/2026-05-03-m7-playlists-crud.md b/docs/superpowers/plans/2026-05-03-m7-playlists-crud.md new file mode 100644 index 00000000..216768d8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-m7-playlists-crud.md @@ -0,0 +1,3508 @@ +# 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 ``'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 ``'s "Add to playlist…" entry. +- `web/src/lib/components/AddToPlaylistMenu.test.ts` +- `web/src/lib/components/PlaylistTrackRow.svelte` — variant of `` 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 ``. +- `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 `/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 /playlist_covers/.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 { + return (await apiFetch('/api/playlists', { method: 'GET' })) as ListPlaylistsResponse; +} + +export async function getPlaylist(id: string): Promise { + 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 { + 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 { + 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 { + await apiFetch(`/api/playlists/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +export async function appendTracks(playlistID: string, trackIDs: string[]): Promise { + 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 { + return (await apiFetch( + `/api/playlists/${encodeURIComponent(playlistID)}/tracks/${position}`, + { method: 'DELETE' } + )) as PlaylistDetail; +} + +export async function reorderPlaylist(playlistID: string, orderedPositions: number[]): Promise { + 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`) 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).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).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).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).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 — `` 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 + + + +``` + +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 — `` 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 + + +
onDragStart?.(row.position)} + ondragover={(e) => { e.preventDefault(); onDragOver?.(e, row.position); }} + ondrop={(e) => { e.preventDefault(); onDrop?.(e, row.position); }} +> + {#if isOwner} + + + + {/if} + + + + {format(row.duration_sec)} + + {#if !isUnavailable && liveTrack} + + + {/if} + + {#if isOwner} + + {/if} +
+``` + +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 — `` + wire into `` + +**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 + + + +``` + +- [ ] **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 + +``` + +Replace with an opener that mounts ``: + +```svelte + { 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} + (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 + + +
+
+

Playlists

+ +
+ + {#if creating} +
+ + {#if createError} +

{createError}

+ {/if} +
+ + +
+
+ {/if} + + {#if $playlistsQ?.isPending} +

Loading playlists…

+ {:else if $playlistsQ?.isError} + $playlistsQ.refetch()} /> + {:else if $playlistsQ?.data} +
+

Your playlists

+ {#if $playlistsQ.data.owned.length === 0} +

No playlists yet. Click "New playlist" to start one.

+ {:else} +
+ {#each $playlistsQ.data.owned as p (p.id)} + + {/each} +
+ {/if} +
+ + {#if $playlistsQ.data.public.length > 0} +
+

From other users

+
+ {#each $playlistsQ.data.public as p (p.id)} + + {/each} +
+
+ {/if} + {/if} +
+``` + +- [ ] **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; + 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; +const mockedCreatePlaylist = createPlaylist as ReturnType; + +function p(over: Partial): 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 + + +
+ {#if $playlistQ?.isPending} +

Loading…

+ {:else if $playlistQ?.isError} + $playlistQ.refetch()} /> + {:else if $playlistQ?.data} + {@const pl = $playlistQ.data} + {#if !editing} +
+
+ {#if pl.cover_url} + + {/if} +
+
+

{pl.name}

+ {#if pl.description} +

{pl.description}

+ {/if} +

+ {pl.track_count} {pl.track_count === 1 ? 'track' : 'tracks'} + {#if pl.is_public}· public{:else}· private{/if} + {#if !isOwner}· by {pl.owner_username}{/if} +

+
+ {#if isOwner} + + + {/if} +
+ {:else} +
+ + + +
+ + +
+
+ {/if} + +
+ {#if pl.tracks.length === 0} +

+ {#if isOwner} + No tracks yet. Add some via the "Add to playlist…" entry on any track row. + {:else} + No tracks in this playlist. + {/if} +

+ {:else} + {#each pl.tracks as row (row.position)} + (dragFromPos = p)} + onDrop={(_, p) => onDrop(p)} + /> + {/each} + {/if} +
+ {/if} +
+``` + +`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; + 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; + +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).