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